diff --git a/.changeset/social-icon-tone.md b/.changeset/social-icon-tone.md
new file mode 100644
index 000000000..fe2c90c5d
--- /dev/null
+++ b/.changeset/social-icon-tone.md
@@ -0,0 +1,17 @@
+---
+"@templatical/types": minor
+"@templatical/renderer": minor
+"@templatical/editor": minor
+"@templatical/import-mjml": patch
+"@templatical/import-topol": patch
+---
+
+Social icons take a tone, separate from their shape
+
+`SocialIconsBlock` gains an optional `iconTone`: `"brand"` (each platform's own color, the default and what an absent value means), `"dark"` or `"light"`. `iconStyle` stays the shape and gains `"plain"`, the glyph alone with no badge or outline. A filled shape draws the glyph white on the tone, or near-black on `"light"`; `outlined` and `plain` draw in the tone. The editor offers both as separate selects, Style and Color.
+
+The renderer ships a PNG set for every style and tone: 6 × 3 sets of 17 platforms, about 1.9 MB in the package. A brand-colored icon keeps its URL, `{style}/{platform}.png`, byte for byte; a tone lives at `{style}-{tone}/{platform}.png`. If you self-host the icons through `socialIconsBaseUrl`, copy the new folders before using a tone or `plain`.
+
+`@templatical/types` exports `SocialIconTone`, `SOCIAL_ICON_STYLES`, `SOCIAL_ICON_TONES`, `SOCIAL_ICON_TONE_COLORS`, `socialIconColors()` and `socialIconAssetDir()`, the rules the editor, the renderer and the PNG build share.
+
+The MJML importer reads the style and tone back from a tone's folder, and the Topol importer maps its black-and-white `outlinedbw` set to `outlined` in `"dark"`.
diff --git a/apps/docs/api/renderer-typescript.md b/apps/docs/api/renderer-typescript.md
index a30a04e63..4118e7b23 100644
--- a/apps/docs/api/renderer-typescript.md
+++ b/apps/docs/api/renderer-typescript.md
@@ -70,7 +70,7 @@ interface RenderOptions {
| `renderCustomBlock` | -- | Resolves custom blocks to HTML. Called once per custom block. Editor consumers pass `editor.renderCustomBlock`; headless consumers wire their own resolver. If omitted, custom blocks fall back to the block's `renderedHtml` field (if present) and otherwise are omitted. |
| `blockRenderers` | `{}` | Per-type renderer overrides keyed by `block.type`. A countdown GIF backend uses this. An override owns the hidden-on-all-viewports check. |
| `getCustomBlockStylesheet` | -- | `(customType) => string \| undefined \| null`. Called once per unique `customType`. Editor consumers pass the registry stylesheet. |
-| `socialIconsBaseUrl` | version-pinned jsDelivr URL | Base URL (no trailing slash) for the social icon PNG assets. Resolved per icon to `${baseUrl}/${style}/${platform}.png`. See [Social icons](#social-icons) below. |
+| `socialIconsBaseUrl` | version-pinned jsDelivr URL | Base URL (no trailing slash) for the social icon PNG assets. Resolved per icon to `${baseUrl}/${dir}/${platform}.png`, where `dir` is the style, plus `-${tone}` for a `dark` or `light` `iconTone`. See [Social icons](#social-icons) below. |
### Custom blocks
@@ -101,10 +101,10 @@ const mjml = await renderToMjml(content, {
### Social icons
-Social icon blocks are emitted as ``. The default `socialIconsBaseUrl` points at the version-pinned jsDelivr mirror of `@templatical/renderer`, which ships pre-rasterized PNGs (every `SocialPlatform` × 5 styles) alongside the package:
+Social icon blocks are emitted as ``. `dir` is the icon style for brand colors (`circle`) and `{style}-{tone}` for an `iconTone` of `dark` or `light` (`circle-dark`). The default `socialIconsBaseUrl` points at the version-pinned jsDelivr mirror of `@templatical/renderer`, which ships pre-rasterized PNGs (every `SocialPlatform` × 6 styles × 3 tones) alongside the package:
```
-https://cdn.jsdelivr.net/npm/@templatical/renderer@/assets/social/{style}/{platform}.png
+https://cdn.jsdelivr.net/npm/@templatical/renderer@/assets/social/{dir}/{platform}.png
```
**Why PNGs.** Outlook desktop (Word rendering engine) does not support SVG and rejects base64 data URIs in ``. Hosted PNGs are the only format that renders across every mainstream email client.
@@ -119,7 +119,7 @@ const mjml = await renderToMjml(content, {
});
```
-The exact filenames the renderer expects are `{style}/{platform}.png` where `style` is one of `solid | outlined | rounded | square | circle` and `platform` is one of `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance | website`. The shipped 192×192 PNGs are a reasonable starting point if you want to mirror them.
+The exact filenames the renderer expects are `{dir}/{platform}.png`, where `dir` is a `style` of `solid | outlined | rounded | square | circle | plain`, followed by `-dark` or `-light` for those colors (a brand-colored icon has no suffix), and `platform` is one of `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance | website`. The shipped 192×192 PNGs are a reasonable starting point if you want to mirror them.
The package also exports `DEFAULT_SOCIAL_ICONS_BASE_URL` if you want to compose URLs against the same default:
diff --git a/apps/docs/api/types.md b/apps/docs/api/types.md
index f12aaba7e..677760c00 100644
--- a/apps/docs/api/types.md
+++ b/apps/docs/api/types.md
@@ -304,6 +304,7 @@ interface SocialIconsBlock extends BaseBlock {
type: 'social';
icons: SocialIcon[];
iconStyle: SocialIconStyle;
+ iconTone?: SocialIconTone; // absent means 'brand'
iconSize: SocialIconSize;
spacing: number;
align: 'left' | 'center' | 'right';
@@ -323,7 +324,9 @@ type SocialPlatform =
| 'website';
-type SocialIconStyle = 'solid' | 'outlined' | 'rounded' | 'square' | 'circle';
+type SocialIconStyle =
+ | 'solid' | 'outlined' | 'rounded' | 'square' | 'circle' | 'plain';
+type SocialIconTone = 'brand' | 'dark' | 'light';
type SocialIconSize = 'small' | 'medium' | 'large';
```
diff --git a/apps/docs/de/api/renderer-typescript.md b/apps/docs/de/api/renderer-typescript.md
index a1805ee10..454871e50 100644
--- a/apps/docs/de/api/renderer-typescript.md
+++ b/apps/docs/de/api/renderer-typescript.md
@@ -70,7 +70,7 @@ interface RenderOptions {
| `renderCustomBlock` | -- | Wandelt benutzerdefinierte Blöcke in HTML um. Wird einmal pro benutzerdefiniertem Block aufgerufen. Editor-Konsumenten übergeben `editor.renderCustomBlock`; Headless-Konsumenten verwenden einen eigenen Resolver. Wenn weggelassen, fällt der Renderer auf das `renderedHtml`-Feld des Blocks zurück (falls vorhanden) und lässt den Block andernfalls weg. |
| `blockRenderers` | `{}` | Typbezogene Renderer-Overrides, keyed nach `block.type`. Ein Countdown-GIF-Backend nutzt dies. Ein Override übernimmt die Prüfung auf „auf allen Viewports ausgeblendet“. |
| `getCustomBlockStylesheet` | -- | `(customType) => string \| undefined \| null`. Wird einmal pro eindeutigem `customType` aufgerufen. Editor-Konsumenten übergeben das Registry-Stylesheet. |
-| `socialIconsBaseUrl` | versionsgebundene jsDelivr-URL | Basis-URL (ohne abschließenden Schrägstrich) für die PNG-Assets der Social-Media-Icons. Wird pro Icon zu `${baseUrl}/${style}/${platform}.png` aufgelöst. Siehe [Social-Media-Icons](#social-media-icons) unten. |
+| `socialIconsBaseUrl` | versionsgebundene jsDelivr-URL | Basis-URL (ohne abschließenden Schrägstrich) für die PNG-Assets der Social-Media-Icons. Wird pro Icon zu `${baseUrl}/${dir}/${platform}.png` aufgelöst, wobei `dir` der Stil ist, bei einem `iconTone` von `dark` oder `light` gefolgt von `-${tone}`. Siehe [Social-Media-Icons](#social-media-icons) unten. |
### Benutzerdefinierte Blöcke
@@ -101,10 +101,10 @@ const mjml = await renderToMjml(content, {
### Social-Media-Icons
-Social-Icon-Blöcke werden als `` ausgegeben. Der Standardwert von `socialIconsBaseUrl` verweist auf den versionsgebundenen jsDelivr-Mirror von `@templatical/renderer`, der vorgerasterte PNGs (jede `SocialPlatform` × 5 Stile) mit dem Paket ausliefert:
+Social-Icon-Blöcke werden als `` ausgegeben. `dir` ist bei Markenfarben der Icon-Stil (`circle`) und bei einem `iconTone` von `dark` oder `light` `{style}-{tone}` (`circle-dark`). Der Standardwert von `socialIconsBaseUrl` verweist auf den versionsgebundenen jsDelivr-Mirror von `@templatical/renderer`, der vorgerasterte PNGs (jede `SocialPlatform` × 6 Stile × 3 Farbtöne) mit dem Paket ausliefert:
```
-https://cdn.jsdelivr.net/npm/@templatical/renderer@/assets/social/{style}/{platform}.png
+https://cdn.jsdelivr.net/npm/@templatical/renderer@/assets/social/{dir}/{platform}.png
```
**Warum PNGs.** Outlook Desktop (Word-Rendering-Engine) unterstützt kein SVG und lehnt base64-Daten-URIs in `` ab. Gehostete PNGs sind das einzige Format, das in allen gängigen E-Mail-Clients zuverlässig dargestellt wird.
@@ -119,7 +119,7 @@ const mjml = await renderToMjml(content, {
});
```
-Die exakten Dateinamen, die der Renderer erwartet, sind `{style}/{platform}.png`, wobei `style` einer von `solid | outlined | rounded | square | circle` und `platform` einer von `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance | website` ist. Die ausgelieferten 192×192-PNGs sind ein sinnvoller Ausgangspunkt, wenn Sie sie spiegeln möchten.
+Die exakten Dateinamen, die der Renderer erwartet, sind `{dir}/{platform}.png`, wobei `dir` ein `style` aus `solid | outlined | rounded | square | circle | plain` ist, für diese Farben gefolgt von `-dark` oder `-light` (ein Icon in Markenfarbe hat kein Suffix), und `platform` einer von `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance | website` ist. Die ausgelieferten 192×192-PNGs sind ein sinnvoller Ausgangspunkt, wenn Sie sie spiegeln möchten.
Das Paket exportiert außerdem `DEFAULT_SOCIAL_ICONS_BASE_URL`, falls Sie URLs gegen denselben Standardwert komponieren möchten:
diff --git a/apps/docs/de/api/types.md b/apps/docs/de/api/types.md
index cc3a70356..829dc465b 100644
--- a/apps/docs/de/api/types.md
+++ b/apps/docs/de/api/types.md
@@ -304,6 +304,7 @@ interface SocialIconsBlock extends BaseBlock {
type: 'social';
icons: SocialIcon[];
iconStyle: SocialIconStyle;
+ iconTone?: SocialIconTone; // fehlt: 'brand'
iconSize: SocialIconSize;
spacing: number;
align: 'left' | 'center' | 'right';
@@ -323,7 +324,9 @@ type SocialPlatform =
| 'website';
-type SocialIconStyle = 'solid' | 'outlined' | 'rounded' | 'square' | 'circle';
+type SocialIconStyle =
+ | 'solid' | 'outlined' | 'rounded' | 'square' | 'circle' | 'plain';
+type SocialIconTone = 'brand' | 'dark' | 'light';
type SocialIconSize = 'small' | 'medium' | 'large';
```
diff --git a/apps/docs/de/guide/blocks.md b/apps/docs/de/guide/blocks.md
index de6b9f61c..dccc5a773 100644
--- a/apps/docs/de/guide/blocks.md
+++ b/apps/docs/de/guide/blocks.md
@@ -123,7 +123,8 @@ Eine Reihe von Social-Media-Icons, die zu Plattformprofilen verlinken.
| Eigenschaft | Typ | Beschreibung |
|----------|------|-------------|
| `icons` | `SocialIcon[]` | Liste der Social Icons |
-| `iconStyle` | `'solid' \| 'outlined' \| 'rounded' \| 'square' \| 'circle'` | Visueller Stil |
+| `iconStyle` | `'solid' \| 'outlined' \| 'rounded' \| 'square' \| 'circle' \| 'plain'` | Form. `plain` ist das Symbol allein, ohne Fläche oder Umriss |
+| `iconTone` | `'brand' \| 'dark' \| 'light'` | Optional. Die Markenfarbe jeder Plattform (Standard) oder ein Farbton für alle Icons. Eine gefüllte Form zeigt das Symbol weiß auf dem Farbton, bei `light` fast schwarz |
| `iconSize` | `'small' \| 'medium' \| 'large'` | Icon-Größe |
| `spacing` | `number` | Abstand zwischen Icons in px |
| `align` | `'left' \| 'center' \| 'right'` | Horizontale Ausrichtung |
diff --git a/apps/docs/guide/blocks.md b/apps/docs/guide/blocks.md
index 30f3198d7..7b3552011 100644
--- a/apps/docs/guide/blocks.md
+++ b/apps/docs/guide/blocks.md
@@ -123,7 +123,8 @@ A row of social media icons linking to platform profiles.
| Property | Type | Description |
|----------|------|-------------|
| `icons` | `SocialIcon[]` | List of social icons |
-| `iconStyle` | `'solid' \| 'outlined' \| 'rounded' \| 'square' \| 'circle'` | Visual style |
+| `iconStyle` | `'solid' \| 'outlined' \| 'rounded' \| 'square' \| 'circle' \| 'plain'` | Shape. `plain` is the glyph alone, with no badge or outline |
+| `iconTone` | `'brand' \| 'dark' \| 'light'` | Optional. Each platform's brand color (the default), or one tone for every icon. A filled shape draws the glyph in white on the tone, or near-black on `light` |
| `iconSize` | `'small' \| 'medium' \| 'large'` | Icon size |
| `spacing` | `number` | Space between icons in px |
| `align` | `'left' \| 'center' \| 'right'` | Horizontal alignment |
diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt
index e439897a0..1d25cdf87 100644
--- a/apps/docs/public/llms-full.txt
+++ b/apps/docs/public/llms-full.txt
@@ -746,7 +746,7 @@ interface RenderOptions {
| `renderCustomBlock` | -- | Resolves custom blocks to HTML. Called once per custom block. Editor consumers pass `editor.renderCustomBlock`; headless consumers wire their own resolver. If omitted, custom blocks fall back to the block's `renderedHtml` field (if present) and otherwise are omitted. |
| `blockRenderers` | `{}` | Per-type renderer overrides keyed by `block.type`. A countdown GIF backend uses this. An override owns the hidden-on-all-viewports check. |
| `getCustomBlockStylesheet` | -- | `(customType) => string \| undefined \| null`. Called once per unique `customType`. Editor consumers pass the registry stylesheet. |
-| `socialIconsBaseUrl` | version-pinned jsDelivr URL | Base URL (no trailing slash) for the social icon PNG assets. Resolved per icon to `${baseUrl}/${style}/${platform}.png`. See [Social icons](#social-icons) below. |
+| `socialIconsBaseUrl` | version-pinned jsDelivr URL | Base URL (no trailing slash) for the social icon PNG assets. Resolved per icon to `${baseUrl}/${dir}/${platform}.png`, where `dir` is the style, plus `-${tone}` for a `dark` or `light` `iconTone`. See [Social icons](#social-icons) below. |
### Custom blocks
@@ -777,10 +777,10 @@ const mjml = await renderToMjml(content, {
### Social icons
-Social icon blocks are emitted as ``. The default `socialIconsBaseUrl` points at the version-pinned jsDelivr mirror of `@templatical/renderer`, which ships pre-rasterized PNGs (every `SocialPlatform` × 5 styles) alongside the package:
+Social icon blocks are emitted as ``. `dir` is the icon style for brand colors (`circle`) and `{style}-{tone}` for an `iconTone` of `dark` or `light` (`circle-dark`). The default `socialIconsBaseUrl` points at the version-pinned jsDelivr mirror of `@templatical/renderer`, which ships pre-rasterized PNGs (every `SocialPlatform` × 6 styles × 3 tones) alongside the package:
```
-https://cdn.jsdelivr.net/npm/@templatical/renderer@/assets/social/{style}/{platform}.png
+https://cdn.jsdelivr.net/npm/@templatical/renderer@/assets/social/{dir}/{platform}.png
```
**Why PNGs.** Outlook desktop (Word rendering engine) does not support SVG and rejects base64 data URIs in ``. Hosted PNGs are the only format that renders across every mainstream email client.
@@ -795,7 +795,7 @@ const mjml = await renderToMjml(content, {
});
```
-The exact filenames the renderer expects are `{style}/{platform}.png` where `style` is one of `solid | outlined | rounded | square | circle` and `platform` is one of `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance | website`. The shipped 192×192 PNGs are a reasonable starting point if you want to mirror them.
+The exact filenames the renderer expects are `{dir}/{platform}.png`, where `dir` is a `style` of `solid | outlined | rounded | square | circle | plain`, followed by `-dark` or `-light` for those colors (a brand-colored icon has no suffix), and `platform` is one of `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance | website`. The shipped 192×192 PNGs are a reasonable starting point if you want to mirror them.
The package also exports `DEFAULT_SOCIAL_ICONS_BASE_URL` if you want to compose URLs against the same default:
@@ -1425,6 +1425,7 @@ interface SocialIconsBlock extends BaseBlock {
type: 'social';
icons: SocialIcon[];
iconStyle: SocialIconStyle;
+ iconTone?: SocialIconTone; // absent means 'brand'
iconSize: SocialIconSize;
spacing: number;
align: 'left' | 'center' | 'right';
@@ -1444,7 +1445,9 @@ type SocialPlatform =
| 'website';
-type SocialIconStyle = 'solid' | 'outlined' | 'rounded' | 'square' | 'circle';
+type SocialIconStyle =
+ | 'solid' | 'outlined' | 'rounded' | 'square' | 'circle' | 'plain';
+type SocialIconTone = 'brand' | 'dark' | 'light';
type SocialIconSize = 'small' | 'medium' | 'large';
```
@@ -4992,7 +4995,8 @@ A row of social media icons linking to platform profiles.
| Property | Type | Description |
|----------|------|-------------|
| `icons` | `SocialIcon[]` | List of social icons |
-| `iconStyle` | `'solid' \| 'outlined' \| 'rounded' \| 'square' \| 'circle'` | Visual style |
+| `iconStyle` | `'solid' \| 'outlined' \| 'rounded' \| 'square' \| 'circle' \| 'plain'` | Shape. `plain` is the glyph alone, with no badge or outline |
+| `iconTone` | `'brand' \| 'dark' \| 'light'` | Optional. Each platform's brand color (the default), or one tone for every icon. A filled shape draws the glyph in white on the tone, or near-black on `light` |
| `iconSize` | `'small' \| 'medium' \| 'large'` | Icon size |
| `spacing` | `number` | Space between icons in px |
| `align` | `'left' \| 'center' \| 'right'` | Horizontal alignment |
diff --git a/packages/editor/src/components/blocks/SocialIconSvg.vue b/packages/editor/src/components/blocks/SocialIconSvg.vue
index e93fd3e06..e64252dbb 100644
--- a/packages/editor/src/components/blocks/SocialIconSvg.vue
+++ b/packages/editor/src/components/blocks/SocialIconSvg.vue
@@ -1,20 +1,25 @@
diff --git a/packages/editor/src/components/blocks/SocialIconsBlock.vue b/packages/editor/src/components/blocks/SocialIconsBlock.vue
index ab56c5a2b..636a76b06 100644
--- a/packages/editor/src/components/blocks/SocialIconsBlock.vue
+++ b/packages/editor/src/components/blocks/SocialIconsBlock.vue
@@ -43,6 +43,7 @@ const hasIcons = computed(() => props.block.icons.length > 0);
diff --git a/packages/editor/src/components/toolbar/SocialToolbar.vue b/packages/editor/src/components/toolbar/SocialToolbar.vue
index 0bf119c8f..4dc2e5407 100644
--- a/packages/editor/src/components/toolbar/SocialToolbar.vue
+++ b/packages/editor/src/components/toolbar/SocialToolbar.vue
@@ -13,6 +13,7 @@ import {
import { socialPlatformOptions } from "../../constants/socialIcons";
import type {
SocialIcon,
+ SocialIconTone,
SocialIconsBlock,
SocialIconStyle,
SocialPlatform,
@@ -130,6 +131,25 @@ function removeSocialIcon(iconId: string): void {
+
+
+
+
+
+
diff --git a/packages/editor/src/i18n/locales/ca.ts b/packages/editor/src/i18n/locales/ca.ts
index 3066c8950..2cf34b5ed 100644
--- a/packages/editor/src/i18n/locales/ca.ts
+++ b/packages/editor/src/i18n/locales/ca.ts
@@ -298,6 +298,11 @@ const ca: typeof en = {
styleRounded: "Arrodonit",
styleSquare: "Quadrat",
styleCircle: "Cercle",
+ stylePlain: "Simple",
+ color: "Color",
+ colorBrand: "Colors de marca",
+ colorDark: "Fosc",
+ colorLight: "Clar",
size: "Mida",
sizeSmall: "S",
sizeMedium: "M",
diff --git a/packages/editor/src/i18n/locales/de.ts b/packages/editor/src/i18n/locales/de.ts
index 4fcccab3c..020a07d87 100644
--- a/packages/editor/src/i18n/locales/de.ts
+++ b/packages/editor/src/i18n/locales/de.ts
@@ -300,6 +300,11 @@ const de: typeof en = {
styleRounded: "Abgerundet",
styleSquare: "Eckig",
styleCircle: "Rund",
+ stylePlain: "Schlicht",
+ color: "Farbe",
+ colorBrand: "Markenfarben",
+ colorDark: "Dunkel",
+ colorLight: "Hell",
size: "Größe",
sizeSmall: "K",
sizeMedium: "M",
diff --git a/packages/editor/src/i18n/locales/en.ts b/packages/editor/src/i18n/locales/en.ts
index fa4facbac..9c1a2ee62 100644
--- a/packages/editor/src/i18n/locales/en.ts
+++ b/packages/editor/src/i18n/locales/en.ts
@@ -295,6 +295,11 @@ export default {
styleRounded: "Rounded",
styleSquare: "Square",
styleCircle: "Circle",
+ stylePlain: "Plain",
+ color: "Color",
+ colorBrand: "Brand colors",
+ colorDark: "Dark",
+ colorLight: "Light",
size: "Size",
sizeSmall: "S",
sizeMedium: "M",
diff --git a/packages/editor/src/i18n/locales/es.ts b/packages/editor/src/i18n/locales/es.ts
index 26e48e30d..f4c04f81e 100644
--- a/packages/editor/src/i18n/locales/es.ts
+++ b/packages/editor/src/i18n/locales/es.ts
@@ -298,6 +298,11 @@ const es: typeof en = {
styleRounded: "Redondeado",
styleSquare: "Cuadrado",
styleCircle: "Círculo",
+ stylePlain: "Simple",
+ color: "Color",
+ colorBrand: "Colores de marca",
+ colorDark: "Oscuro",
+ colorLight: "Claro",
size: "Tamaño",
sizeSmall: "S",
sizeMedium: "M",
diff --git a/packages/editor/src/i18n/locales/fr.ts b/packages/editor/src/i18n/locales/fr.ts
index 1322ce861..f7e252d45 100644
--- a/packages/editor/src/i18n/locales/fr.ts
+++ b/packages/editor/src/i18n/locales/fr.ts
@@ -263,6 +263,11 @@ const fr: typeof en = {
styleRounded: "Arrondi",
styleSquare: "Carré",
styleCircle: "Cercle",
+ stylePlain: "Simple",
+ color: "Couleur",
+ colorBrand: "Couleurs de marque",
+ colorDark: "Sombre",
+ colorLight: "Clair",
size: "Taille",
sizeSmall: "S",
sizeMedium: "M",
diff --git a/packages/editor/src/i18n/locales/ja.ts b/packages/editor/src/i18n/locales/ja.ts
index 58e6e2ce5..50de8cecd 100644
--- a/packages/editor/src/i18n/locales/ja.ts
+++ b/packages/editor/src/i18n/locales/ja.ts
@@ -249,6 +249,11 @@ const translations: typeof en = {
styleRounded: "角丸",
styleSquare: "四角",
styleCircle: "丸",
+ stylePlain: "アイコンのみ",
+ color: "色",
+ colorBrand: "ブランドカラー",
+ colorDark: "ダーク",
+ colorLight: "ライト",
size: "サイズ",
sizeSmall: "小",
sizeMedium: "中",
diff --git a/packages/editor/src/i18n/locales/nl.ts b/packages/editor/src/i18n/locales/nl.ts
index 943468abe..de7eaa529 100644
--- a/packages/editor/src/i18n/locales/nl.ts
+++ b/packages/editor/src/i18n/locales/nl.ts
@@ -264,6 +264,11 @@ const nl: typeof en = {
styleRounded: "Afgerond",
styleSquare: "Vierkant",
styleCircle: "Cirkel",
+ stylePlain: "Eenvoudig",
+ color: "Kleur",
+ colorBrand: "Merkkleuren",
+ colorDark: "Donker",
+ colorLight: "Licht",
size: "Grootte",
sizeSmall: "S",
sizeMedium: "M",
diff --git a/packages/editor/src/i18n/locales/pt-BR.ts b/packages/editor/src/i18n/locales/pt-BR.ts
index ee68fb700..1734f7116 100644
--- a/packages/editor/src/i18n/locales/pt-BR.ts
+++ b/packages/editor/src/i18n/locales/pt-BR.ts
@@ -297,6 +297,11 @@ const ptBR: typeof en = {
styleRounded: "Arredondado",
styleSquare: "Quadrado",
styleCircle: "Circular",
+ stylePlain: "Simples",
+ color: "Cor",
+ colorBrand: "Cores da marca",
+ colorDark: "Escuro",
+ colorLight: "Claro",
size: "Tamanho",
sizeSmall: "P",
sizeMedium: "M",
diff --git a/packages/editor/tests/socialIconTone.test.ts b/packages/editor/tests/socialIconTone.test.ts
new file mode 100644
index 000000000..331b5a7e7
--- /dev/null
+++ b/packages/editor/tests/socialIconTone.test.ts
@@ -0,0 +1,96 @@
+// @vitest-environment happy-dom
+import { describe, expect, it } from "vitest";
+import { mount } from "@vue/test-utils";
+import {
+ SOCIAL_ICON_GLYPHS,
+ SOCIAL_ICON_TONE_COLORS,
+ type SocialIconTone,
+ type SocialIconStyle,
+} from "@templatical/types";
+import SocialIconSvg from "../src/components/blocks/SocialIconSvg.vue";
+import SocialIconsBlock from "../src/components/blocks/SocialIconsBlock.vue";
+import { mountEditor } from "./helpers/mount";
+import { createSocialIconsBlock } from "@templatical/types";
+
+/**
+ * The canvas draws icons as CSS plus an inline SVG glyph; the renderer sends
+ * PNGs. Both take their colors from `socialIconColors`, so these pin the
+ * canvas side of each style and color against that shared rule.
+ */
+
+function draw(iconStyle: SocialIconStyle, iconTone?: SocialIconTone) {
+ const wrapper = mount(SocialIconSvg, {
+ props: { platform: "github", iconStyle, iconTone, iconSize: "medium" },
+ });
+ const box = wrapper.find("span").element as HTMLElement;
+ const svg = wrapper.find("svg");
+ return {
+ background: box.style.backgroundColor,
+ border: box.style.border,
+ glyph: svg.attributes("fill"),
+ glyphSize: svg.attributes("width"),
+ };
+}
+
+/** A hex color as the DOM reports it back from an inline style. */
+function css(hex: string): string {
+ const el = document.createElement("span");
+ el.style.backgroundColor = hex;
+ return el.style.backgroundColor;
+}
+
+describe("SocialIconSvg colors", () => {
+ it("keeps the brand look when no color is set", () => {
+ expect(draw("circle")).toMatchObject({
+ background: css(SOCIAL_ICON_GLYPHS.github.color),
+ glyph: "#ffffff",
+ });
+ expect(draw("circle")).toEqual(draw("circle", "brand"));
+ });
+
+ it("fills a badge with the tone and keeps the glyph white", () => {
+ expect(draw("rounded", "dark")).toMatchObject({
+ background: css(SOCIAL_ICON_TONE_COLORS.dark),
+ glyph: "#ffffff",
+ });
+ });
+
+ it("puts a near-black glyph on a light badge", () => {
+ expect(draw("solid", "light").glyph).toBe(SOCIAL_ICON_TONE_COLORS.dark);
+ });
+
+ it("draws an outline and its glyph in the tone", () => {
+ const { border, glyph } = draw("outlined", "dark");
+ expect(glyph).toBe(SOCIAL_ICON_TONE_COLORS.dark);
+ const probe = document.createElement("span");
+ probe.style.border = `2px solid ${SOCIAL_ICON_TONE_COLORS.dark}`;
+ expect(border).toBe(probe.style.border);
+ });
+
+ it("draws a plain icon as a larger glyph in the tone, with no badge", () => {
+ const plain = draw("plain", "light");
+ expect(plain.background).toBe("");
+ expect(plain.border).toBe("");
+ expect(plain.glyph).toBe(SOCIAL_ICON_TONE_COLORS.light);
+ // 84% of the 32px icon, against 60% inside a badge.
+ expect(plain.glyphSize).toBe("26");
+ expect(draw("circle").glyphSize).toBe("19");
+ });
+});
+
+describe("SocialIconsBlock", () => {
+ it("hands the block's tone to every icon it draws", () => {
+ const block = createSocialIconsBlock({
+ icons: [{ platform: "github", url: "https://github.com" }],
+ iconStyle: "plain",
+ iconTone: "light",
+ });
+ const wrapper = mountEditor(SocialIconsBlock, {
+ props: { block, viewport: "desktop" },
+ });
+
+ expect(wrapper.find("svg").attributes("fill")).toBe(
+ SOCIAL_ICON_TONE_COLORS.light,
+ );
+ });
+});
diff --git a/packages/editor/tests/socialToolbar.test.ts b/packages/editor/tests/socialToolbar.test.ts
index d0b4e920a..689a8a817 100644
--- a/packages/editor/tests/socialToolbar.test.ts
+++ b/packages/editor/tests/socialToolbar.test.ts
@@ -95,3 +95,30 @@ describe('SocialToolbar CRUD', () => {
expect(update.icons![0].id).toBe('b');
});
});
+
+describe('SocialToolbar style and color', () => {
+ it('offers the plain style', () => {
+ const wrapper = mountIt(withIcons([]));
+ const values = wrapper
+ .findAll('option')
+ .map((o) => (o.element as HTMLOptionElement).value);
+ expect(values).toContain('plain');
+ });
+
+ it('shows brand for a block with no color and emits the chosen tone', async () => {
+ // Content saved before iconTone existed has no such field.
+ const { iconTone: _absent, ...legacy } = withIcons([]);
+ const wrapper = mountIt(legacy as SocialIconsBlock);
+ const select = wrapper.find('[data-testid="social-tone-select"]');
+ expect((select.element as HTMLSelectElement).value).toBe('brand');
+ expect(
+ select.findAll('option').map((o) => (o.element as HTMLOptionElement).value),
+ ).toEqual(['brand', 'dark', 'light']);
+
+ await select.setValue('light');
+ const [update] = wrapper.emitted('update')!.at(-1) as [
+ Partial,
+ ];
+ expect(update).toEqual({ iconTone: 'light' });
+ });
+});
diff --git a/packages/import-mjml/src/__tests__/composite-mapper.test.ts b/packages/import-mjml/src/__tests__/composite-mapper.test.ts
index d0ec1ed0b..636aea8b8 100644
--- a/packages/import-mjml/src/__tests__/composite-mapper.test.ts
+++ b/packages/import-mjml/src/__tests__/composite-mapper.test.ts
@@ -97,6 +97,24 @@ describe("mj-social", () => {
expect(block.spacing).toBe(14);
});
+ it("reads the style and tone from a one-tone icon folder", () => {
+ const styleOf = (dir: string) => {
+ const block = convert(
+ `
+
+ `,
+ "mj-social",
+ ).result.block as SocialIconsBlock;
+ return [block.iconStyle, block.iconTone];
+ };
+
+ expect(styleOf("circle-dark")).toEqual(["circle", "dark"]);
+ expect(styleOf("plain-light")).toEqual(["plain", "light"]);
+ expect(styleOf("plain")).toEqual(["plain", undefined]);
+ // An unknown tone is not a Templatical folder: the radius decides.
+ expect(styleOf("rounded-neon")).toEqual(["circle", undefined]);
+ });
+
it("maps x to twitter and strips a -noshare suffix", () => {
const { result } = convert(
`
diff --git a/packages/import-mjml/src/__tests__/round-trip.test.ts b/packages/import-mjml/src/__tests__/round-trip.test.ts
index 0af053595..2157292c8 100644
--- a/packages/import-mjml/src/__tests__/round-trip.test.ts
+++ b/packages/import-mjml/src/__tests__/round-trip.test.ts
@@ -114,6 +114,7 @@ function buildFixtureTemplate(): TemplateContent {
},
],
iconStyle: "circle",
+ iconTone: "dark",
iconSize: "medium",
spacing: 14,
}),
diff --git a/packages/import-mjml/src/composite-mapper.ts b/packages/import-mjml/src/composite-mapper.ts
index 01cf9011f..af0c7d4aa 100644
--- a/packages/import-mjml/src/composite-mapper.ts
+++ b/packages/import-mjml/src/composite-mapper.ts
@@ -4,10 +4,13 @@ import {
createSocialIconsBlock,
createTableBlock,
generateId,
+ SOCIAL_ICON_TONES,
+ SOCIAL_ICON_STYLES,
} from "@templatical/types";
import type {
MenuItemData,
SocialIcon,
+ SocialIconTone,
SocialIconSize,
SocialIconStyle,
SocialPlatform,
@@ -99,7 +102,7 @@ function normalizePlatform(raw: string): SocialPlatform | null {
return null;
}
-/** The `