xertica-ui 2.8.0 → 2.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +1 -1
  3. package/bin/cli.ts +327 -87
  4. package/bin/language-config.ts +58 -18
  5. package/components/blocks/auth/AuthPageShell.tsx +63 -18
  6. package/components/blocks/auth/SocialLoginButtons.tsx +3 -3
  7. package/components/blocks/index.ts +1 -0
  8. package/components/blocks/marketing/CTASection.tsx +73 -0
  9. package/components/blocks/marketing/ComplianceBadgeRow.tsx +49 -0
  10. package/components/blocks/marketing/EvidenceTimeline.tsx +91 -0
  11. package/components/blocks/marketing/FeatureRow.tsx +78 -0
  12. package/components/blocks/marketing/Hero.tsx +125 -0
  13. package/components/blocks/marketing/HowItWorksSteps.tsx +90 -0
  14. package/components/blocks/marketing/LogoCloud.tsx +47 -0
  15. package/components/blocks/marketing/MarketingFooter.tsx +109 -0
  16. package/components/blocks/marketing/MarketingNavbar.tsx +192 -0
  17. package/components/blocks/marketing/PillBadge.tsx +96 -0
  18. package/components/blocks/marketing/SectionHeading.tsx +82 -0
  19. package/components/blocks/marketing/ServiceCardGrid.tsx +238 -0
  20. package/components/blocks/marketing/StatBand.tsx +99 -0
  21. package/components/blocks/marketing/TopAccentCards.tsx +91 -0
  22. package/components/blocks/marketing/index.ts +41 -0
  23. package/components/brand/index.ts +1 -0
  24. package/components/brand/isotype/Isotype.tsx +64 -0
  25. package/components/brand/isotype/IsotypeDiagonal.tsx +51 -0
  26. package/components/brand/isotype/IsotypeFrames.tsx +82 -0
  27. package/components/brand/isotype/IsotypeMini.tsx +65 -0
  28. package/components/brand/isotype/IsotypeTwist.tsx +68 -0
  29. package/components/brand/isotype/frame-variants.ts +20 -0
  30. package/components/brand/isotype/index.ts +9 -0
  31. package/components/brand/isotype/isotype-diagonal.mdx +52 -0
  32. package/components/brand/isotype/isotype-diagonal.stories.tsx +44 -0
  33. package/components/brand/isotype/isotype-diagonal.test.tsx +180 -0
  34. package/components/brand/isotype/isotype-frames.mdx +50 -0
  35. package/components/brand/isotype/isotype-frames.stories.tsx +63 -0
  36. package/components/brand/isotype/isotype-frames.test.tsx +92 -0
  37. package/components/brand/isotype/isotype-mini.mdx +61 -0
  38. package/components/brand/isotype/isotype-mini.stories.tsx +75 -0
  39. package/components/brand/isotype/isotype-mini.test.tsx +45 -0
  40. package/components/brand/isotype/isotype-twist.test.tsx +71 -0
  41. package/components/brand/isotype/isotype.mdx +61 -0
  42. package/components/brand/isotype/isotype.stories.tsx +74 -0
  43. package/components/brand/isotype/isotype.test.tsx +41 -0
  44. package/components/brand/isotype/mini-patterns.ts +50 -0
  45. package/components/brand/isotype/patterns.ts +83 -0
  46. package/components/brand/isotype/twist-variants.ts +20 -0
  47. package/components/pages/forgot-password-page/ForgotPasswordPage.tsx +3 -6
  48. package/components/pages/index.ts +1 -0
  49. package/components/pages/landing-page/LandingPage.tsx +360 -0
  50. package/components/pages/landing-page/index.ts +1 -0
  51. package/components/pages/login-page/LoginPage.tsx +3 -6
  52. package/components/pages/reset-password-page/ResetPasswordPage.tsx +1 -4
  53. package/components/pages/verify-email-page/VerifyEmailPage.tsx +2 -5
  54. package/components/ui/button/button.test.tsx +1 -1
  55. package/components/ui/button/button.tsx +7 -6
  56. package/components/ui/card/card.test.tsx +12 -0
  57. package/components/ui/card/card.tsx +5 -3
  58. package/components/ui/input-otp/input-otp.test.tsx +14 -1
  59. package/contexts/BrandColorsContext.tsx +9 -5
  60. package/contexts/theme-data.ts +32 -5
  61. package/dist/AssistantChart-BU48lxL2.js +3383 -0
  62. package/dist/AssistantChart-CoN6ga6Z.js +3383 -0
  63. package/dist/AssistantChart-DIRYJ7WY.js +3383 -0
  64. package/dist/AssistantChart-DOnY1ZlD.cjs +3551 -0
  65. package/dist/AssistantChart-DjM3qnp5.cjs +3551 -0
  66. package/dist/AssistantChart-hxPyE1S9.cjs +3551 -0
  67. package/dist/AudioPlayer-CpihsbaD.js +1278 -0
  68. package/dist/AudioPlayer-CvGajzMC.cjs +1282 -0
  69. package/dist/AudioPlayer-CvHpFqDG.cjs +1282 -0
  70. package/dist/AudioPlayer-D4S7GQou.js +1278 -0
  71. package/dist/BrandColorsContext-BNKLUVcU.js +829 -0
  72. package/dist/BrandColorsContext-DNZGHItQ.cjs +836 -0
  73. package/dist/BrandColorsContext-Ug_TZ8yn.cjs +846 -0
  74. package/dist/BrandColorsContext-z0O2LlZz.js +839 -0
  75. package/dist/CodeBlock-BkaZf-R8.cjs +223 -0
  76. package/dist/CodeBlock-CKu8LgbP.cjs +223 -0
  77. package/dist/CodeBlock-DiLS5LLI.js +221 -0
  78. package/dist/CodeBlock-zdylBGdZ.js +221 -0
  79. package/dist/CustomTooltipContent-BAR5qCQg.js +33 -0
  80. package/dist/CustomTooltipContent-CBLHBxX8.cjs +54 -0
  81. package/dist/CustomTooltipContent-CjWm3jjV.js +33 -0
  82. package/dist/CustomTooltipContent-DTV9MN9E.cjs +54 -0
  83. package/dist/EvidenceTimeline-DrRk4yqb.cjs +560 -0
  84. package/dist/EvidenceTimeline-l4MaV4C7.js +546 -0
  85. package/dist/FeatureCard-CIbBHqeY.cjs +497 -0
  86. package/dist/FeatureCard-DtoKPSfW.cjs +497 -0
  87. package/dist/FeatureCard-P4iEpBmB.js +485 -0
  88. package/dist/FeatureCard-e3f8bnF0.js +485 -0
  89. package/dist/ImageWithFallback-C1ww9Hg3.cjs +122 -0
  90. package/dist/ImageWithFallback-CBrkAEo3.cjs +122 -0
  91. package/dist/ImageWithFallback-CGk5zPH-.js +98 -0
  92. package/dist/ImageWithFallback-CfnoFFSP.js +98 -0
  93. package/dist/Isotype--fRZWwS6.js +85 -0
  94. package/dist/Isotype-0dpLqetB.js +85 -0
  95. package/dist/Isotype-B8XLEXSB.cjs +92 -0
  96. package/dist/Isotype-ChKwN7e-.cjs +91 -0
  97. package/dist/IsotypeDiagonal-CuPYPP4f.js +84 -0
  98. package/dist/IsotypeDiagonal-eNcOXRAc.cjs +91 -0
  99. package/dist/IsotypeDiagonal-oiwpXZhE.js +104 -0
  100. package/dist/IsotypeDiagonal-xMFoFXkb.cjs +109 -0
  101. package/dist/IsotypeFrames-BcWtyAWp.cjs +83 -0
  102. package/dist/IsotypeFrames-BnvSJKiH.cjs +73 -0
  103. package/dist/IsotypeFrames-iDRTRuIu.js +79 -0
  104. package/dist/IsotypeFrames-qoXOvOJt.js +69 -0
  105. package/dist/IsotypeMini-CQtWVo3h.js +126 -0
  106. package/dist/IsotypeMini-Cu_sW5yN.cjs +134 -0
  107. package/dist/IsotypeTwist-BTdNL4iV.js +106 -0
  108. package/dist/IsotypeTwist-DvsUgZs_.cjs +115 -0
  109. package/dist/LanguageContext-B1aOXO-K.js +1987 -0
  110. package/dist/LanguageContext-BP0k7AzI.js +1993 -0
  111. package/dist/LanguageContext-CChcIHIG.cjs +1998 -0
  112. package/dist/LanguageContext-ubC7TSs_.cjs +1992 -0
  113. package/dist/LanguageSelector-BLFPXGo8.cjs +48 -0
  114. package/dist/LanguageSelector-CYRzWkRZ.cjs +48 -0
  115. package/dist/LanguageSelector-CyHjZgC4.cjs +48 -0
  116. package/dist/LanguageSelector-DE8lIySO.js +46 -0
  117. package/dist/LanguageSelector-DUr1hjBW.js +46 -0
  118. package/dist/LanguageSelector-DjbWIKWC.js +46 -0
  119. package/dist/LogoCloud-C_uMKVNg.cjs +513 -0
  120. package/dist/LogoCloud-DJsMPNPh.js +500 -0
  121. package/dist/MarketingFooter-B_ycEdTJ.cjs +727 -0
  122. package/dist/MarketingFooter-Bhx-2Vlr.cjs +727 -0
  123. package/dist/MarketingFooter-Br1MGK9e.js +699 -0
  124. package/dist/MarketingFooter-BwE1vmeX.cjs +700 -0
  125. package/dist/MarketingFooter-CsZ1_xL9.js +709 -0
  126. package/dist/MarketingFooter-DCwf78Q4.cjs +716 -0
  127. package/dist/MarketingFooter-KH2r-OOL.js +709 -0
  128. package/dist/MarketingFooter-nB7G7gQ5.js +682 -0
  129. package/dist/SectionHeading-DEeOKcAd.cjs +857 -0
  130. package/dist/SectionHeading-DMNObcwm.js +838 -0
  131. package/dist/ThemeToggle--CGJjQJv.cjs +219 -0
  132. package/dist/ThemeToggle-Bn_7Pmfe.js +215 -0
  133. package/dist/ThemeToggle-CoL8Ady7.js +215 -0
  134. package/dist/ThemeToggle-D2Wzm5bn.cjs +219 -0
  135. package/dist/VerifyEmailPage-90lK6pP4.js +3258 -0
  136. package/dist/VerifyEmailPage-BJX1Rter.cjs +3320 -0
  137. package/dist/VerifyEmailPage-BSWvOgry.js +3310 -0
  138. package/dist/VerifyEmailPage-CDqIkhx-.js +3224 -0
  139. package/dist/VerifyEmailPage-Crxje3ZB.js +3323 -0
  140. package/dist/VerifyEmailPage-D2ShbuEN.cjs +3333 -0
  141. package/dist/VerifyEmailPage-DEBZXQ7c.cjs +3310 -0
  142. package/dist/VerifyEmailPage-DGaX-jZv.cjs +3268 -0
  143. package/dist/VerifyEmailPage-ZtOPkgOA.cjs +3234 -0
  144. package/dist/VerifyEmailPage-s927GSiR.js +3300 -0
  145. package/dist/XerticaOrbe-BHex09xs.js +1925 -0
  146. package/dist/XerticaOrbe-BKW3LJEz.js +1925 -0
  147. package/dist/XerticaOrbe-Br2i99U_.cjs +1927 -0
  148. package/dist/XerticaOrbe-kjFmuyx-.cjs +1927 -0
  149. package/dist/XerticaProvider-Bi5lKC9N.js +44 -0
  150. package/dist/XerticaProvider-BrPMVhiv.js +44 -0
  151. package/dist/XerticaProvider-CnX5kt5W.cjs +46 -0
  152. package/dist/XerticaProvider-DEKNJTlN.js +44 -0
  153. package/dist/XerticaProvider-X_wavDYA.cjs +46 -0
  154. package/dist/XerticaProvider-b3n_oL8w.cjs +46 -0
  155. package/dist/alert-dialog--qdlmB5i.cjs +872 -0
  156. package/dist/alert-dialog-C48TOdYU.cjs +872 -0
  157. package/dist/alert-dialog-CEPpnpXt.js +819 -0
  158. package/dist/alert-dialog-NjflitDa.js +819 -0
  159. package/dist/assistant.cjs.js +2 -2
  160. package/dist/assistant.es.js +2 -2
  161. package/dist/avatar-CtEKgg7s.cjs +77 -0
  162. package/dist/avatar-D03MNQxs.js +54 -0
  163. package/dist/avatar-Df7KgSvs.js +54 -0
  164. package/dist/avatar-Gy0_Xu9i.cjs +77 -0
  165. package/dist/blocks.cjs.js +33 -18
  166. package/dist/blocks.es.js +2 -2
  167. package/dist/brand.cjs.js +110 -4
  168. package/dist/brand.es.js +98 -4
  169. package/dist/breadcrumb-BGO5PZPG.js +90 -0
  170. package/dist/breadcrumb-BvGAY4ne.cjs +98 -0
  171. package/dist/breadcrumb-CU-punra.js +90 -0
  172. package/dist/breadcrumb-DjV9wfX1.cjs +98 -0
  173. package/dist/button-B05czY_8.js +62 -0
  174. package/dist/button-B3SE1liJ.js +62 -0
  175. package/dist/button-BMDlWZR8.cjs +85 -0
  176. package/dist/button-DVhloDtr.cjs +85 -0
  177. package/dist/cli.js +280 -62
  178. package/dist/components/blocks/auth/AuthPageShell.d.ts +21 -5
  179. package/dist/components/blocks/index.d.ts +1 -0
  180. package/dist/components/blocks/marketing/CTASection.d.ts +28 -0
  181. package/dist/components/blocks/marketing/ComplianceBadgeRow.d.ts +23 -0
  182. package/dist/components/blocks/marketing/EvidenceTimeline.d.ts +26 -0
  183. package/dist/components/blocks/marketing/FeatureRow.d.ts +25 -0
  184. package/dist/components/blocks/marketing/Hero.d.ts +35 -0
  185. package/dist/components/blocks/marketing/HowItWorksSteps.d.ts +29 -0
  186. package/dist/components/blocks/marketing/LogoCloud.d.ts +22 -0
  187. package/dist/components/blocks/marketing/MarketingFooter.d.ts +42 -0
  188. package/dist/components/blocks/marketing/MarketingNavbar.d.ts +47 -0
  189. package/dist/components/blocks/marketing/PillBadge.d.ts +21 -0
  190. package/dist/components/blocks/marketing/SectionHeading.d.ts +23 -0
  191. package/dist/components/blocks/marketing/ServiceCardGrid.d.ts +65 -0
  192. package/dist/components/blocks/marketing/StatBand.d.ts +25 -0
  193. package/dist/components/blocks/marketing/TopAccentCards.d.ts +29 -0
  194. package/dist/components/blocks/marketing/index.d.ts +28 -0
  195. package/dist/components/brand/index.d.ts +1 -0
  196. package/dist/components/brand/isotype/Isotype.d.ts +27 -0
  197. package/dist/components/brand/isotype/IsotypeDiagonal.d.ts +19 -0
  198. package/dist/components/brand/isotype/IsotypeFrames.d.ts +21 -0
  199. package/dist/components/brand/isotype/IsotypeMini.d.ts +26 -0
  200. package/dist/components/brand/isotype/IsotypeTwist.d.ts +22 -0
  201. package/dist/components/brand/isotype/frame-variants.d.ts +11 -0
  202. package/dist/components/brand/isotype/index.d.ts +9 -0
  203. package/dist/components/brand/isotype/mini-patterns.d.ts +25 -0
  204. package/dist/components/brand/isotype/patterns.d.ts +42 -0
  205. package/dist/components/brand/isotype/twist-variants.d.ts +11 -0
  206. package/dist/components/pages/forgot-password-page/ForgotPasswordPage.d.ts +2 -2
  207. package/dist/components/pages/index.d.ts +1 -0
  208. package/dist/components/pages/landing-page/LandingPage.d.ts +18 -0
  209. package/dist/components/pages/landing-page/index.d.ts +1 -0
  210. package/dist/components/pages/login-page/LoginPage.d.ts +2 -2
  211. package/dist/components/pages/verify-email-page/VerifyEmailPage.d.ts +1 -1
  212. package/dist/components/ui/card/card.d.ts +3 -1
  213. package/dist/contexts/theme-data.d.ts +6 -0
  214. package/dist/dropdown-menu-ChQa9zW3.cjs +225 -0
  215. package/dist/dropdown-menu-DRvnsQAt.cjs +225 -0
  216. package/dist/dropdown-menu-DUk81i5Y.js +190 -0
  217. package/dist/dropdown-menu-NlUHhVmK.js +190 -0
  218. package/dist/hooks.cjs.js +2 -2
  219. package/dist/hooks.es.js +2 -2
  220. package/dist/index.cjs.js +57 -41
  221. package/dist/index.es.js +25 -25
  222. package/dist/input-Csp_Metb.cjs +91 -0
  223. package/dist/input-DVRwGVLx.js +65 -0
  224. package/dist/input-Dqyj7Hdw.cjs +91 -0
  225. package/dist/input-dOgrFQ-3.js +65 -0
  226. package/dist/layout.cjs.js +1 -1
  227. package/dist/layout.es.js +1 -1
  228. package/dist/media.cjs.js +1 -1
  229. package/dist/media.es.js +1 -1
  230. package/dist/pages.cjs.js +2 -1
  231. package/dist/pages.es.js +1 -1
  232. package/dist/rich-text-editor-7K7UjLJN.cjs +2968 -0
  233. package/dist/rich-text-editor-B3jLP2bm.cjs +2968 -0
  234. package/dist/rich-text-editor-BntMVQsB.js +2894 -0
  235. package/dist/rich-text-editor-Diz_onyB.js +2894 -0
  236. package/dist/rich-text-editor-RWvK4hd3.cjs +2968 -0
  237. package/dist/rich-text-editor-gauEkzPM.js +2894 -0
  238. package/dist/select-B6PygS38.cjs +161 -0
  239. package/dist/select-BD763J5Y.cjs +161 -0
  240. package/dist/select-QJDKZITN.js +130 -0
  241. package/dist/select-q7aDfuPn.js +130 -0
  242. package/dist/sidebar-Bkk1YAsD.cjs +1154 -0
  243. package/dist/sidebar-CuzYeOSz.cjs +1154 -0
  244. package/dist/sidebar-CwlW3LXE.js +1151 -0
  245. package/dist/sidebar-DNkf-Ug6.js +1151 -0
  246. package/dist/sidebar-DaS3fplw.cjs +1154 -0
  247. package/dist/sidebar-FDCY42oP.js +1151 -0
  248. package/dist/skeleton-BtBwJ39W.js +79 -0
  249. package/dist/skeleton-CJyg0eJ1.cjs +88 -0
  250. package/dist/skeleton-D8WaauK0.cjs +88 -0
  251. package/dist/skeleton-DBYtjaZW.cjs +88 -0
  252. package/dist/skeleton-DY_lSFWf.js +79 -0
  253. package/dist/skeleton-Df65Ox10.js +79 -0
  254. package/dist/slider-BKV_vvsh.cjs +78 -0
  255. package/dist/slider-B_lUjFNa.cjs +78 -0
  256. package/dist/slider-CtBpIaU4.js +56 -0
  257. package/dist/slider-KgWmSJbx.js +56 -0
  258. package/dist/tooltip-BPttuPxU.js +48 -0
  259. package/dist/tooltip-CIKRzQqE.cjs +72 -0
  260. package/dist/tooltip-CoDByjE_.cjs +72 -0
  261. package/dist/tooltip-_MAwsiOL.js +48 -0
  262. package/dist/ui.cjs.js +13 -13
  263. package/dist/ui.es.js +13 -13
  264. package/dist/xertica-assistant-B1xAl2Vn.js +2173 -0
  265. package/dist/xertica-assistant-BBUMaUXw.js +2173 -0
  266. package/dist/xertica-assistant-CWuBwfzt.cjs +2180 -0
  267. package/dist/xertica-assistant-D2hlwiEP.js +2173 -0
  268. package/dist/xertica-assistant-DN2J_sQQ.cjs +2180 -0
  269. package/dist/xertica-assistant-DdYS6WXT.cjs +2180 -0
  270. package/dist/xertica-ui.css +1 -1
  271. package/llms-compact.txt +1 -1
  272. package/llms.txt +1 -1
  273. package/package.json +4 -2
  274. package/styles/xertica/base.css +8 -0
  275. package/styles/xertica/tokens.css +1 -0
  276. package/templates/package.json +1 -1
  277. package/templates/src/app/components/AuthGuard.tsx +6 -0
  278. package/templates/src/features/auth/ui/ForgotPasswordContent.tsx +1 -4
  279. package/templates/src/features/auth/ui/LoginContent.tsx +1 -4
  280. package/templates/src/features/auth/ui/ResetPasswordContent.tsx +1 -4
  281. package/templates/src/features/auth/ui/VerifyEmailContent.tsx +1 -4
  282. package/templates/src/locales/en/pages/landing.json +175 -0
  283. package/templates/src/locales/es/pages/landing.json +175 -0
  284. package/templates/src/locales/pt-BR/pages/landing.json +175 -0
  285. package/templates/src/pages/LandingPage.tsx +349 -0
  286. package/templates/src/styles/xertica/tokens.css +1 -0
@@ -0,0 +1,65 @@
1
+ import * as React from 'react';
2
+ import { cn } from '../../shared/utils';
3
+ import { ISOTYPE_COLORS } from './patterns';
4
+ import {
5
+ getIsotypeMiniVariant,
6
+ ISOTYPE_MINI_PATTERNS,
7
+ type IsotypeMiniPatternId,
8
+ } from './mini-patterns';
9
+
10
+ export interface IsotypeMiniProps extends React.HTMLAttributes<HTMLDivElement> {
11
+ /** Which registered pattern to render. */
12
+ pattern?: IsotypeMiniPatternId;
13
+ /**
14
+ * Deterministic seed selecting one cell to render as blank. Omit to render
15
+ * the pure, fully-filled base pattern — the correct choice for canonical
16
+ * brand-mark placements.
17
+ */
18
+ variantSeed?: number;
19
+ /** Accessible label. Omit for purely decorative use. */
20
+ 'aria-label'?: string;
21
+ }
22
+
23
+ /**
24
+ * A 3x3 grid brand mark — the third in the Xertica "isotipos" series, a
25
+ * compact sibling of the 4x4 `Isotype`. Cells render flush against each
26
+ * other so the grid reads as a single composed image.
27
+ *
28
+ * @ai-rules
29
+ * 1. Colors are fixed brand hex values (see `patterns.ts`), not theme tokens — never make them theme-reactive.
30
+ * 2. Never add a `gap-*` utility or per-cell border between cells — it breaks the "single image" look.
31
+ * 3. Add new patterns via `ISOTYPE_MINI_PATTERNS`/`IsotypeMiniPatternId` in `mini-patterns.ts`; this component never needs to change.
32
+ * 4. Only pass `variantSeed` for decorative/repeated placements — canonical brand-mark spots should use the pure base pattern.
33
+ */
34
+ export function IsotypeMini({
35
+ pattern = 'core',
36
+ variantSeed,
37
+ className,
38
+ 'aria-label': ariaLabel,
39
+ ...props
40
+ }: IsotypeMiniProps) {
41
+ const cells =
42
+ variantSeed === undefined
43
+ ? ISOTYPE_MINI_PATTERNS[pattern]
44
+ : getIsotypeMiniVariant(pattern, variantSeed);
45
+
46
+ return (
47
+ <div
48
+ role={ariaLabel ? 'img' : undefined}
49
+ aria-label={ariaLabel}
50
+ aria-hidden={ariaLabel ? undefined : true}
51
+ className={cn(
52
+ 'grid aspect-square grid-cols-3 grid-rows-3',
53
+ className
54
+ )}
55
+ {...props}
56
+ >
57
+ {cells.map((cell, index) => (
58
+ <div
59
+ key={index}
60
+ style={{ backgroundColor: cell === 'blank' ? undefined : ISOTYPE_COLORS[cell] }}
61
+ />
62
+ ))}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,68 @@
1
+ import * as React from 'react';
2
+ import { cn } from '../../shared/utils';
3
+ import { ISOTYPE_COLORS } from './patterns';
4
+ import { ISOTYPE_TWIST_VARIANTS, type IsotypeTwistVariantId } from './twist-variants';
5
+
6
+ export interface IsotypeTwistProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ /** Which registered color-pair variant to render. */
8
+ variant?: IsotypeTwistVariantId;
9
+ /** Accessible label. Omit for purely decorative use. */
10
+ 'aria-label'?: string;
11
+ }
12
+
13
+ const VIEW_WIDTH = 800;
14
+ const VIEW_HEIGHT = 694;
15
+ /** CSS blend mode used to combine the two shapes — the intersection color is whatever this mode produces, not a fixed value. */
16
+ const TWIST_BLEND_MODE: React.CSSProperties['mixBlendMode'] = 'multiply';
17
+
18
+ /** Back shape of the pair — one fixed hand-derived composition, not data-driven. */
19
+ const BACK_SHAPE_PATH =
20
+ 'M218.149 0L344.167 230.665L470.185 0H681.981L467.008 344.092L687.805 693.902H473.892L344.167 458.948L214.443 693.902H0L221.326 344.092L5.82437 0H218.149Z';
21
+ /** Front shape of the pair — overlaps the back shape; dominates the intersection blend. */
22
+ const FRONT_SHAPE_PATH =
23
+ 'M486.064 230.488L570.299 384.535L654.535 230.488H796.107L652.411 460.285L800 693.902H657.012L570.299 536.991L483.586 693.902H340.244L488.187 460.285L344.137 230.488H486.064Z';
24
+
25
+ /**
26
+ * Two overlapping angular ribbon shapes — the fifth mark in the Xertica
27
+ * "isotipos" series. Both shapes render fully opaque; their intersection
28
+ * blends into a third color via a CSS mix-blend-mode, not transparency.
29
+ *
30
+ * @ai-rules
31
+ * 1. Colors are fixed brand hex values (see `patterns.ts`), not theme tokens — never make them theme-reactive.
32
+ * 2. The intersection color is a rendering side-effect of `TWIST_BLEND_MODE`, not data — never hardcode a third color per variant.
33
+ * 3. Add new color pairs via `ISOTYPE_TWIST_VARIANTS` in `twist-variants.ts`; this component never needs to change.
34
+ * 4. Pair order is `[back, front]` — the front (second) color dominates the intersection blend.
35
+ * 5. The root `isolate` class scopes the blend to these two shapes — removing it would let the front shape blend against whatever sits behind this component on the page.
36
+ * 6. The viewBox is not square (800x694) — the root uses `aspect-[800/694]`, not `aspect-square`.
37
+ */
38
+ export function IsotypeTwist({
39
+ variant = 'magenta-blue',
40
+ className,
41
+ 'aria-label': ariaLabel,
42
+ ...props
43
+ }: IsotypeTwistProps) {
44
+ const [backKey, frontKey] = ISOTYPE_TWIST_VARIANTS[variant];
45
+
46
+ return (
47
+ <div
48
+ role={ariaLabel ? 'img' : undefined}
49
+ aria-label={ariaLabel}
50
+ aria-hidden={ariaLabel ? undefined : true}
51
+ className={cn('isolate aspect-[800/694]', className)}
52
+ {...props}
53
+ >
54
+ <svg
55
+ viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
56
+ className="h-full w-full"
57
+ aria-hidden="true"
58
+ >
59
+ <path d={BACK_SHAPE_PATH} fill={ISOTYPE_COLORS[backKey]} />
60
+ <path
61
+ d={FRONT_SHAPE_PATH}
62
+ fill={ISOTYPE_COLORS[frontKey]}
63
+ style={{ mixBlendMode: TWIST_BLEND_MODE }}
64
+ />
65
+ </svg>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,20 @@
1
+ import type { IsotypeColorKey } from './patterns';
2
+
3
+ /**
4
+ * Ordered `[back, front]` color pair for `IsotypeFrames`. The front (second)
5
+ * color is painted on top and dominates the intersection blend produced by
6
+ * stroke opacity — the blended color is never hardcoded, only derived.
7
+ */
8
+ export type IsotypeFramePair = readonly [IsotypeColorKey, IsotypeColorKey];
9
+
10
+ /** Only these two brand color pairs are in rotation — widen deliberately, not speculatively. */
11
+ export type IsotypeFrameVariantId = 'blue-magenta' | 'blue-yellow';
12
+
13
+ export const ISOTYPE_FRAME_VARIANTS: Record<IsotypeFrameVariantId, IsotypeFramePair> = {
14
+ 'blue-magenta': ['blue', 'magenta'],
15
+ 'blue-yellow': ['blue', 'yellow'],
16
+ };
17
+
18
+ export const ISOTYPE_FRAME_VARIANT_IDS = Object.keys(
19
+ ISOTYPE_FRAME_VARIANTS
20
+ ) as IsotypeFrameVariantId[];
@@ -0,0 +1,9 @@
1
+ export * from './Isotype';
2
+ export * from './patterns';
3
+ export * from './IsotypeFrames';
4
+ export * from './frame-variants';
5
+ export * from './IsotypeMini';
6
+ export * from './mini-patterns';
7
+ export * from './IsotypeDiagonal';
8
+ export * from './IsotypeTwist';
9
+ export * from './twist-variants';
@@ -0,0 +1,52 @@
1
+ import { Meta, Title, Subtitle } from '@storybook/addon-docs/blocks';
2
+
3
+ <Meta title="Brand/Isotype/Diagonal" />
4
+
5
+ <Title>Isotype — Diagonal</Title>
6
+ <Subtitle>Third mark in the Xertica isotipos series: a diagonal-cut variation of the core grid.</Subtitle>
7
+
8
+ ---
9
+
10
+ ## Overview
11
+
12
+ `IsotypeDiagonal` renders a fixed diagonal-cut variation of the "core" grid isotype. Instead of flat rectangular cells, three color-pair boundaries are cut so they form triangular tips — no props change the geometry or colors, it's a single fixed composition.
13
+
14
+ ```tsx
15
+ import { IsotypeDiagonal } from 'xertica-ui/brand';
16
+
17
+ <IsotypeDiagonal className="w-64" />
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Props
23
+
24
+ | Prop | Type | Default | Description |
25
+ |---|---|---|---|
26
+ | `className` | `string` | — | Additional classes on the root — use for sizing (`w-64`, `max-w-md`, etc). `aspect-square` is built in. |
27
+ | `aria-label` | `string` | — | Omit for decorative use (root gets `aria-hidden`); set it to render as `role="img"` instead. |
28
+
29
+ ---
30
+
31
+ ## Geometria
32
+
33
+ The composition fills a `0 0 100 100` viewBox with 8 shapes (4 rects + 4 polygons). Three color-pair boundaries all lie on the same corner-to-corner diagonal of the square:
34
+
35
+ - The **blue/green** tip, at `(100,0)`–`(75,25)`
36
+ - The **magenta/red** tip, at `(0,100)`–`(25,75)`
37
+ - The **yellow** hypotenuse, at `(75,25)`–`(25,75)`
38
+
39
+ Every vertex on these three edges satisfies `x + y = 100` — the same line, which is what produces the single continuous diagonal across the mark. Unlike the grid `Isotype`/`IsotypeMini`, this geometry is not data-driven: it's one fixed hand-derived composition, so there is no pattern/variant prop.
40
+
41
+ `blue` and `red` are not the small triangles they appear to be — they're full-width backing rectangles (`blue` spans the whole top row, `red` the whole bottom row), painted first. `purple` and `green` are painted on top of `blue`; `magenta` and `brown` are painted on top of `red`. What reads as a blue or red triangle is just whatever of that backing rectangle the shapes on top don't cover. This layering exists to close a rendering seam — see AI Best Practices below.
42
+
43
+ ---
44
+
45
+ ## AI Best Practices
46
+
47
+ > [!IMPORTANT]
48
+ > - **Colors are fixed brand hex values, not theme tokens** — same rule as the grid `Isotype`: never replace `ISOTYPE_COLORS` with CSS custom properties.
49
+ > - **All 8 shapes are geometrically interdependent** — never move one vertex/rect without re-checking every seam it backs or is backed by.
50
+ > - **This is one fixed composition, not a data-driven pattern** — there is no variant/pattern prop, and none should be added speculatively.
51
+ > - **The blank polygon's `fill="none"` is required, not cosmetic** — an SVG shape with no `fill` renders solid black, unlike a CSS `background-color` left unset.
52
+ > - **`blue`/`red` are oversized backing rectangles that `purple`/`green`/`magenta`/`brown` deliberately overlap, in that paint order** — this closes the hairline anti-aliasing seam browsers otherwise render between two adjacent shapes even when their edges match exactly. The seam is only visible where a shape's edge borders empty space; painting the tip shapes over a same-color rectangle means any imperfect edge coverage reveals more of the matching color instead of the page background. Two earlier techniques were tried and rejected for this: a same-color `stroke` on every shape (fixed the gap but left visible miter-join artifacts where 3–4 shapes meet at one vertex), and a per-shape `transform: scale(...)` from each shape's bounding-box center (broke alignment entirely, since different shapes have different bounding-box centers). Don't reintroduce either — don't shrink `blue`/`red` back to their visible triangle, and don't move `purple`/`green`/`magenta`/`brown` earlier than `blue`/`red` in paint order.
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { IsotypeDiagonal } from './IsotypeDiagonal';
3
+ import React from 'react';
4
+
5
+ const meta: Meta<typeof IsotypeDiagonal> = {
6
+ title: 'Brand/Isotype/Diagonal',
7
+ component: IsotypeDiagonal,
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ };
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof IsotypeDiagonal>;
15
+
16
+ export const Base: Story = {
17
+ args: {
18
+ className: 'w-64',
19
+ },
20
+ };
21
+
22
+ export const Sizes: Story = {
23
+ render: () => (
24
+ <div className="flex flex-col items-start gap-6 p-6">
25
+ <IsotypeDiagonal className="w-16" />
26
+ <IsotypeDiagonal className="w-32" />
27
+ <IsotypeDiagonal className="w-64" />
28
+ <IsotypeDiagonal className="w-96" />
29
+ </div>
30
+ ),
31
+ };
32
+
33
+ export const OnDark: Story = {
34
+ args: {
35
+ className: 'w-64',
36
+ },
37
+ decorators: [
38
+ Story => (
39
+ <div className="rounded-xl bg-slate-950 p-8">
40
+ <Story />
41
+ </div>
42
+ ),
43
+ ],
44
+ };
@@ -0,0 +1,180 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { IsotypeDiagonal } from './IsotypeDiagonal';
4
+ import { ISOTYPE_COLORS } from './patterns';
5
+ import React from 'react';
6
+
7
+ type Point = [number, number];
8
+
9
+ function shapeWithFill(container: HTMLElement, fill: string) {
10
+ return container.querySelector(`[fill="${fill}"]`)!;
11
+ }
12
+
13
+ function rectCorners(rect: Element): Point[] {
14
+ const x = Number(rect.getAttribute('x'));
15
+ const y = Number(rect.getAttribute('y'));
16
+ const width = Number(rect.getAttribute('width'));
17
+ const height = Number(rect.getAttribute('height'));
18
+ return [
19
+ [x, y],
20
+ [x + width, y],
21
+ [x + width, y + height],
22
+ [x, y + height],
23
+ ];
24
+ }
25
+
26
+ function polygonPoints(polygon: Element): Point[] {
27
+ return polygon
28
+ .getAttribute('points')!
29
+ .trim()
30
+ .split(/\s+/)
31
+ .map(pair => pair.split(',').map(Number) as Point);
32
+ }
33
+
34
+ function shapePoints(shape: Element): Point[] {
35
+ return shape.tagName === 'rect' ? rectCorners(shape) : polygonPoints(shape);
36
+ }
37
+
38
+ function shapeEdges(shape: Element): [Point, Point][] {
39
+ const points = shapePoints(shape);
40
+ return points.map((point, i) => [point, points[(i + 1) % points.length]]);
41
+ }
42
+
43
+ /** True if `shape` has an edge between `a` and `b`, in either direction. */
44
+ function hasEdge(shape: Element, a: Point, b: Point): boolean {
45
+ return shapeEdges(shape).some(
46
+ ([p, q]) =>
47
+ (p[0] === a[0] && p[1] === a[1] && q[0] === b[0] && q[1] === b[1]) ||
48
+ (p[0] === b[0] && p[1] === b[1] && q[0] === a[0] && q[1] === a[1])
49
+ );
50
+ }
51
+
52
+ describe('IsotypeDiagonal', () => {
53
+ it('renders 4 rects (blue, red, purple, brown) and 4 polygons (green, magenta, yellow, blank)', () => {
54
+ const { container } = render(<IsotypeDiagonal />);
55
+ expect(container.querySelectorAll('rect')).toHaveLength(4);
56
+ expect(container.querySelectorAll('polygon')).toHaveLength(4);
57
+ });
58
+
59
+ it('uses every ISOTYPE_COLORS value exactly once as a fill', () => {
60
+ const { container } = render(<IsotypeDiagonal />);
61
+ Object.values(ISOTYPE_COLORS).forEach(color => {
62
+ expect(container.querySelectorAll(`[fill="${color}"]`)).toHaveLength(1);
63
+ });
64
+ });
65
+
66
+ it('renders exactly one shape with fill="none"', () => {
67
+ const { container } = render(<IsotypeDiagonal />);
68
+ expect(container.querySelectorAll('[fill="none"]')).toHaveLength(1);
69
+ });
70
+
71
+ it('renders no shape with a stroke', () => {
72
+ const { container } = render(<IsotypeDiagonal />);
73
+ container.querySelectorAll('rect, polygon').forEach(shape => {
74
+ expect(shape.getAttribute('stroke')).toBeNull();
75
+ });
76
+ });
77
+
78
+ it('passes className through to the root', () => {
79
+ const { container } = render(<IsotypeDiagonal className="w-64" />);
80
+ expect((container.firstChild as HTMLElement).className).toContain('w-64');
81
+ });
82
+
83
+ it('is decorative by default (aria-hidden, no role)', () => {
84
+ const { container } = render(<IsotypeDiagonal />);
85
+ const root = container.firstChild as HTMLElement;
86
+ expect(root.getAttribute('aria-hidden')).toBe('true');
87
+ expect(root.getAttribute('role')).toBeNull();
88
+ });
89
+
90
+ it('renders as an image with a label when aria-label is set', () => {
91
+ const { container } = render(<IsotypeDiagonal aria-label="Xertica isotype" />);
92
+ const root = container.firstChild as HTMLElement;
93
+ expect(root.getAttribute('role')).toBe('img');
94
+ expect(root.getAttribute('aria-label')).toBe('Xertica isotype');
95
+ expect(root.getAttribute('aria-hidden')).toBeNull();
96
+ });
97
+
98
+ it('uses a 0 0 100 100 viewBox', () => {
99
+ const { container } = render(<IsotypeDiagonal />);
100
+ expect(container.querySelector('svg')!.getAttribute('viewBox')).toBe('0 0 100 100');
101
+ });
102
+
103
+ it('backs blue and red with full-width rectangles, wider than the triangular area they end up showing', () => {
104
+ const { container } = render(<IsotypeDiagonal />);
105
+ const blue = shapeWithFill(container, ISOTYPE_COLORS.blue);
106
+ const red = shapeWithFill(container, ISOTYPE_COLORS.red);
107
+
108
+ expect(blue.tagName).toBe('rect');
109
+ expect(red.tagName).toBe('rect');
110
+ expect(rectCorners(blue)).toEqual([
111
+ [0, 0],
112
+ [100, 0],
113
+ [100, 25],
114
+ [0, 25],
115
+ ]);
116
+ expect(rectCorners(red)).toEqual([
117
+ [0, 75],
118
+ [100, 75],
119
+ [100, 100],
120
+ [0, 100],
121
+ ]);
122
+ });
123
+
124
+ it('paints purple/green after blue, and magenta/brown after red, so they overlap their backing rectangle instead of abutting it', () => {
125
+ const { container } = render(<IsotypeDiagonal />);
126
+ const shapes = Array.from(container.querySelectorAll('rect, polygon'));
127
+ const indexOf = (color: string) => shapes.indexOf(shapeWithFill(container, color));
128
+
129
+ expect(indexOf(ISOTYPE_COLORS.blue)).toBeLessThan(indexOf(ISOTYPE_COLORS.purple));
130
+ expect(indexOf(ISOTYPE_COLORS.blue)).toBeLessThan(indexOf(ISOTYPE_COLORS.green));
131
+ expect(indexOf(ISOTYPE_COLORS.red)).toBeLessThan(indexOf(ISOTYPE_COLORS.magenta));
132
+ expect(indexOf(ISOTYPE_COLORS.red)).toBeLessThan(indexOf(ISOTYPE_COLORS.brown));
133
+ });
134
+
135
+ it('keeps purple and brown fully within the blue/red rectangles they sit on top of', () => {
136
+ const { container } = render(<IsotypeDiagonal />);
137
+ const purple = shapeWithFill(container, ISOTYPE_COLORS.purple);
138
+ const brown = shapeWithFill(container, ISOTYPE_COLORS.brown);
139
+
140
+ expect(Number(purple.getAttribute('y'))).toBeGreaterThanOrEqual(0);
141
+ expect(Number(purple.getAttribute('y')) + Number(purple.getAttribute('height'))).toBeLessThanOrEqual(25);
142
+
143
+ expect(Number(brown.getAttribute('y'))).toBeGreaterThanOrEqual(75);
144
+ expect(Number(brown.getAttribute('y')) + Number(brown.getAttribute('height'))).toBeLessThanOrEqual(100);
145
+ });
146
+
147
+ it('keeps the untouched seams — purple/magenta, magenta/blank, yellow/green, and brown/green — matching exactly', () => {
148
+ const { container } = render(<IsotypeDiagonal />);
149
+ const purple = shapeWithFill(container, ISOTYPE_COLORS.purple);
150
+ const magenta = shapeWithFill(container, ISOTYPE_COLORS.magenta);
151
+ const blank = shapeWithFill(container, 'none');
152
+ const yellow = shapeWithFill(container, ISOTYPE_COLORS.yellow);
153
+ const green = shapeWithFill(container, ISOTYPE_COLORS.green);
154
+ const brown = shapeWithFill(container, ISOTYPE_COLORS.brown);
155
+
156
+ expect(hasEdge(purple, [0, 25], [25, 25])).toBe(true);
157
+ expect(hasEdge(magenta, [0, 25], [25, 25])).toBe(true);
158
+
159
+ expect(hasEdge(magenta, [25, 25], [25, 75])).toBe(true);
160
+ expect(hasEdge(blank, [25, 25], [25, 75])).toBe(true);
161
+
162
+ expect(hasEdge(yellow, [75, 25], [75, 75])).toBe(true);
163
+ expect(hasEdge(green, [75, 25], [75, 75])).toBe(true);
164
+
165
+ expect(hasEdge(brown, [75, 75], [100, 75])).toBe(true);
166
+ expect(hasEdge(green, [75, 75], [100, 75])).toBe(true);
167
+ });
168
+
169
+ it('traces a single continuous diagonal through the blue/green tip, the magenta/red tip, and the yellow hypotenuse', () => {
170
+ const diagonalVertices: Point[] = [
171
+ [100, 0],
172
+ [0, 100],
173
+ [75, 25],
174
+ [25, 75],
175
+ ];
176
+ diagonalVertices.forEach(([x, y]) => {
177
+ expect(x + y).toBe(100);
178
+ });
179
+ });
180
+ });
@@ -0,0 +1,50 @@
1
+ import { Meta, Title, Subtitle } from '@storybook/addon-docs/blocks';
2
+
3
+ <Meta title="Brand/Isotype/Frames" />
4
+
5
+ <Title>Isotype — Frames</Title>
6
+ <Subtitle>Second mark in the Xertica isotipos series: two overlapping hollow squares.</Subtitle>
7
+
8
+ ---
9
+
10
+ ## Overview
11
+
12
+ `IsotypeFrames` renders two overlapping hollow square frames in fixed brand colors. Their intersection blends into a third color purely from SVG stroke opacity — no third color is ever hardcoded, it's a rendering side-effect.
13
+
14
+ ```tsx
15
+ import { IsotypeFrames } from 'xertica-ui/brand';
16
+
17
+ <IsotypeFrames variant="blue-magenta" className="w-64" />
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Props
23
+
24
+ | Prop | Type | Default | Description |
25
+ |---|---|---|---|
26
+ | `variant` | `IsotypeFrameVariantId` | `'blue-magenta'` | Which registered color pair to render. |
27
+ | `className` | `string` | — | Additional classes on the root — use for sizing (`w-64`, `max-w-md`, etc). `aspect-square` is built in. |
28
+ | `aria-label` | `string` | — | Omit for decorative use (root gets `aria-hidden`); set it to render as `role="img"` instead. |
29
+
30
+ ---
31
+
32
+ ## Variants
33
+
34
+ The registry (`ISOTYPE_FRAME_VARIANTS` in `frame-variants.ts`) holds ordered `[back, front]` color pairs — the front (second) color is painted on top and dominates the intersection blend. Add a new color combination by adding an entry there and widening `IsotypeFrameVariantId`; `IsotypeFrames.tsx` itself never needs to change.
35
+
36
+ ```tsx
37
+ import { ISOTYPE_FRAME_VARIANT_IDS, IsotypeFrames } from 'xertica-ui/brand';
38
+
39
+ ISOTYPE_FRAME_VARIANT_IDS.map(variant => <IsotypeFrames key={variant} variant={variant} />);
40
+ ```
41
+
42
+ ---
43
+
44
+ ## AI Best Practices
45
+
46
+ > [!IMPORTANT]
47
+ > - **Colors are fixed brand hex values, not theme tokens** — same rule as the grid `Isotype`: never replace `ISOTYPE_COLORS` with CSS custom properties.
48
+ > - **The intersection color is derived, not data** — it comes from stroke opacity compositing; never hardcode a third color per variant.
49
+ > - **New color pairs are data, not components** — add variants via `ISOTYPE_FRAME_VARIANTS`/`IsotypeFrameVariantId` in `frame-variants.ts`.
50
+ > - **Pair order controls the blend** — `[back, front]`, the front color dominates the intersection.
@@ -0,0 +1,63 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { IsotypeFrames } from './IsotypeFrames';
3
+ import { ISOTYPE_FRAME_VARIANT_IDS } from './frame-variants';
4
+ import React from 'react';
5
+
6
+ const meta: Meta<typeof IsotypeFrames> = {
7
+ title: 'Brand/Isotype/Frames',
8
+ component: IsotypeFrames,
9
+ parameters: {
10
+ layout: 'centered',
11
+ },
12
+ argTypes: {
13
+ variant: {
14
+ control: 'select',
15
+ options: ISOTYPE_FRAME_VARIANT_IDS,
16
+ },
17
+ },
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof IsotypeFrames>;
22
+
23
+ export const Base: Story = {
24
+ args: {
25
+ variant: 'blue-magenta',
26
+ className: 'w-64',
27
+ },
28
+ };
29
+
30
+ export const AllVariants: Story = {
31
+ render: () => (
32
+ <div className="grid grid-cols-3 gap-8 p-6">
33
+ {ISOTYPE_FRAME_VARIANT_IDS.map(variant => (
34
+ <IsotypeFrames key={variant} variant={variant} className="w-40" />
35
+ ))}
36
+ </div>
37
+ ),
38
+ };
39
+
40
+ export const Sizes: Story = {
41
+ render: () => (
42
+ <div className="flex flex-col items-start gap-6 p-6">
43
+ <IsotypeFrames className="w-16" />
44
+ <IsotypeFrames className="w-32" />
45
+ <IsotypeFrames className="w-64" />
46
+ <IsotypeFrames className="w-96" />
47
+ </div>
48
+ ),
49
+ };
50
+
51
+ export const OnDark: Story = {
52
+ args: {
53
+ variant: 'blue-magenta',
54
+ className: 'w-64',
55
+ },
56
+ decorators: [
57
+ Story => (
58
+ <div className="rounded-xl bg-slate-950 p-8">
59
+ <Story />
60
+ </div>
61
+ ),
62
+ ],
63
+ };
@@ -0,0 +1,92 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { IsotypeFrames } from './IsotypeFrames';
4
+ import { ISOTYPE_FRAME_VARIANTS, ISOTYPE_FRAME_VARIANT_IDS } from './frame-variants';
5
+ import { ISOTYPE_COLORS } from './patterns';
6
+ import React from 'react';
7
+
8
+ describe('IsotypeFrames', () => {
9
+ it('renders two overlapping rects', () => {
10
+ const { container } = render(<IsotypeFrames />);
11
+ expect(container.querySelectorAll('rect')).toHaveLength(2);
12
+ });
13
+
14
+ it('defaults to the blue-magenta variant', () => {
15
+ const { container } = render(<IsotypeFrames />);
16
+ const rects = container.querySelectorAll('rect');
17
+ const [backKey, frontKey] = ISOTYPE_FRAME_VARIANTS['blue-magenta'];
18
+ expect(rects[0].getAttribute('stroke')).toBe(ISOTYPE_COLORS[backKey]);
19
+ expect(rects[1].getAttribute('stroke')).toBe(ISOTYPE_COLORS[frontKey]);
20
+ });
21
+
22
+ it('renders every registered variant with the right stroke colors', () => {
23
+ for (const variant of ISOTYPE_FRAME_VARIANT_IDS) {
24
+ const { container } = render(<IsotypeFrames variant={variant} />);
25
+ const rects = container.querySelectorAll('rect');
26
+ const [backKey, frontKey] = ISOTYPE_FRAME_VARIANTS[variant];
27
+ expect(rects[0].getAttribute('stroke')).toBe(ISOTYPE_COLORS[backKey]);
28
+ expect(rects[1].getAttribute('stroke')).toBe(ISOTYPE_COLORS[frontKey]);
29
+ }
30
+ });
31
+
32
+ it('renders each rect hollow (fill="none")', () => {
33
+ const { container } = render(<IsotypeFrames />);
34
+ container.querySelectorAll('rect').forEach(rect => {
35
+ expect(rect.getAttribute('fill')).toBe('none');
36
+ });
37
+ });
38
+
39
+ it('passes className through to the root', () => {
40
+ const { container } = render(<IsotypeFrames className="w-64" />);
41
+ expect((container.firstChild as HTMLElement).className).toContain('w-64');
42
+ });
43
+
44
+ it('offsets the front frame diagonally from the back frame by a sixth of the shared size', () => {
45
+ const { container } = render(<IsotypeFrames />);
46
+ const [back, front] = Array.from(container.querySelectorAll('rect'));
47
+
48
+ const backX = Number(back.getAttribute('x'));
49
+ const backY = Number(back.getAttribute('y'));
50
+ const backSize = Number(back.getAttribute('width'));
51
+ const frontX = Number(front.getAttribute('x'));
52
+ const frontY = Number(front.getAttribute('y'));
53
+ const frontSize = Number(front.getAttribute('width'));
54
+
55
+ expect(backSize).toBe(frontSize);
56
+ expect(frontX - backX).toBeCloseTo(backSize / 6);
57
+ expect(frontY - backY).toBeCloseTo(backSize / 6);
58
+ });
59
+
60
+ it('renders both frames fully opaque and blends their intersection via mix-blend-mode on the front frame', () => {
61
+ const { container } = render(<IsotypeFrames />);
62
+ const [back, front] = Array.from(container.querySelectorAll('rect'));
63
+
64
+ expect(back.getAttribute('opacity')).toBeNull();
65
+ expect(front.getAttribute('opacity')).toBeNull();
66
+ expect(front.style.mixBlendMode).toBeTruthy();
67
+ expect(front.style.mixBlendMode).not.toBe('normal');
68
+ });
69
+
70
+ it('isolates the blend mode to the two frames via the root isolate class', () => {
71
+ const { container } = render(<IsotypeFrames />);
72
+ expect((container.firstChild as HTMLElement).className).toContain('isolate');
73
+ });
74
+
75
+ it('expands the viewBox so no edge of the stroked frames is clipped', () => {
76
+ const { container } = render(<IsotypeFrames />);
77
+ const svg = container.querySelector('svg')!;
78
+ const [minX, minY, width, height] = svg.getAttribute('viewBox')!.split(' ').map(Number);
79
+
80
+ container.querySelectorAll('rect').forEach(rect => {
81
+ const x = Number(rect.getAttribute('x'));
82
+ const y = Number(rect.getAttribute('y'));
83
+ const size = Number(rect.getAttribute('width'));
84
+ const half = Number(rect.getAttribute('stroke-width')) / 2;
85
+
86
+ expect(x - half).toBeGreaterThanOrEqual(minX);
87
+ expect(y - half).toBeGreaterThanOrEqual(minY);
88
+ expect(x + size + half).toBeLessThanOrEqual(minX + width);
89
+ expect(y + size + half).toBeLessThanOrEqual(minY + height);
90
+ });
91
+ });
92
+ });