@gemboss/gem-framework 0.12.1 → 0.14.0

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ gem-framework của GemTheme đóng thành package, cho các repo **ngoài** mon
9
9
  | `…/sections` | 4 hàm `headless.tsx` được import (`elementOverrides`, `imageSrcSet`, `subscriptionPlans`, `defaultPlan`) | `packages/gem-sections/src` |
10
10
  | `…/section-gate` | `validateSectionArtifacts`: ĐÚNG bộ kiểm section gemtheme dùng khi duyệt. Từ 0.3.0 thêm ba cổng của thư viện cho MỘT section: `layoutKnobCheck` (+ `LAYOUT_ATTRS`), `slotContractDrift` (+ `slotsReadBy`, `slotSourceFromElementTree`), `cssPairingOrphans` (+ `classesStyledBy`, truyền CSS từ `createGemThemeCss`). Từ 0.3.2 thêm hai cổng pháp lý: `fabricatedProofInSource` / `fabricatedProofInSchema` (điểm, số lượt, người, lời hứa bịa) và `inventedTestimonyIn` (hằng DEMO_*_QUOTE/REVIEW… làm giá trị khi thiếu dữ liệu). Từ 0.3.3 hai cổng này đọc được tiếng Việt ("4,8/5", "đã bán", "miễn phí vận chuyển", "199k", cả chữ NFD), tra giá trị của `DEMO.<key>` ở mọi chỗ đọc nó (`??`, `\|\|`, toán tử ba ngôi, dòng bị Prettier ngắt), và bỏ qua số lượng hiển thị (`reviews_to_show`…). Nâng từ 0.3.2 lên có thể làm đỏ section trước đó xanh: đó là lời khẳng định nằm trong DEMO. Từ 0.3.5 bắt thêm chữ ký người Việt (tên kèm nơi chốn như "Chị Lan, Hà Nội", hoặc họ + Thị/Văn như "Nguyễn Thị Hoa"; tiêu đề viết hoa từng chữ thì không), điểm sao ("4,9 ★"), `rating: 4.9` / `reviewCount: "1.284"` trong mảng sản phẩm mẫu, và hằng demo ở nhánh else của toán tử ba ngôi (`bound ? [] : DEMO_REVIEWS`). Mỗi thứ bịa chỉ báo MỘT lần: tại dòng DEMO nếu dòng đó tự lộ, không thì tại chỗ đọc. "đồng hồ" không còn bị đọc là giá. Từ 0.3.6 bắt lời hứa có thời hạn đứng sau từ khoá ("Dùng thử 30 ngày", "Bảo hành 12 tháng", "Nhận trong 2–4 ngày", "Giao 2 giờ") và "hoàn tiền" không kèm số; "Chính sách hoàn tiền" vẫn là nhãn. Từ 0.3.7 câu hỏi (kết thúc bằng "?") không bao giờ là lời hứa, "không hợp lệ"/số bước/nhịp giao không bị báo, và bắt thêm "trong vòng N ngày", "Giao hàng 2–4 ngày", "Bảo hành 1 đổi 1…", "Try it free for 30 days", "Ships in 24h". Từ 0.3.8 xuất thêm `claimIn` (câu hỏi "một chuỗi này có phải lời khẳng định, người bịa, hay lời hứa?"). Từ 0.3.9 bắt "cancel anytime", "guaranteed delivery", "huỷ bất cứ lúc nào" (chính sách huỷ gói và giao hàng). Từ 0.3.10 lời từ chối ("cannot be canceled", "Không thể hủy") không bị báo, "giao dịch/giao diện" không phải giao hàng, "Delivery not guaranteed" là lời miễn trừ, một chuỗi chỉ gồm con số viết tắt ("199k", "12k+") mang nhãn trung tính "hardcoded figure" (không phân biệt được giá với số đếm), và `stringLiteralsIn(line)` thay cho hằng `STRING_LITERAL` (đã gỡ: một regex `/g` dùng chung rò `lastIndex`; 0 chỗ dùng ở gemboss/gemfactory/gemsite). Từ 0.3.11 "Huỷ bỏ lọc…" và "Hủy đơn, gọi chúng tôi bất cứ lúc nào" không bị đọc là chính sách huỷ; "huỷ đăng ký" là bỏ nhận tin (như "Unsubscribe anytime"); `SOURCE.json` ghi `gemtheme_dirty`, và `npm pack` từ chối khi ĐẦU VÀO của gói (`packages/`, gói này trừ `package.json`, `scripts/lib/`) có thay đổi chưa commit hoặc git không trả lời được. Từ 0.3.12 bắt lời hứa hoàn tiền không có chữ "hoàn tiền" liền nhau ("nhận lại toàn bộ tiền", "Hoàn lại 100% tiền", "Trả lại tiền nếu không hài lòng"); lời từ chối ("Không được hoàn lại tiền") và tiền thừa/thối khi thanh toán không bị bắt Từ 0.4.0: `demoOutsidePreview(tsx)` (chữ mẫu DEMO chỉ hiện trong bản xem trước, tức khi `section.demo === true`; plan xuất bản không bao giờ mang cờ đó) và schema `SectionSpec` khai trường `demo` | `packages/gem-sections/src/section-artifacts.ts`, `section-contract.ts` |
11
11
  | `…/settings-schema` | `validateSettingsSchema` | `scripts/lib/settings-schema-contract.mjs` |
12
- | `…/gem-framework.css`, `…/gem-runtime.js` | CSS token mặc định + runtime mọi plugin, dựng sẵn. `gem-runtime.js` tự chạy khi tải: đăng ký và lên lịch đủ 22 plugin | build |
12
+ | `…/gem-framework.css`, `…/gem-runtime.js` | CSS token mặc định + runtime mọi plugin, dựng sẵn. `gem-runtime.js` tự chạy khi tải: đăng ký và lên lịch đủ 23 plugin | build |
13
13
  | `…/tokens.css`, `…/primitives.css`, `…/sections.css`, `…/popup-shell.css` | Từ 0.8.0: `gem-framework.css` cắt ba (từ 0.8.3 cắt bốn), nối lại theo đúng thứ tự đó thì bằng `gem-framework.css` từng byte (build dừng nếu không bằng). `tokens.css`: biến `:root` + núm style. `primitives.css`: reset, phần tử nền, tiện ích, mọi primitive `.gem-*`. `sections.css`: khung header/footer, lớp section chung, profile theme. `popup-shell.css` (0.8.3): khung popup `.gem-popup-shell`, chỉ trang có popup mới cần tải, đặt sau `primitives.css` và trước `sections.css` (thứ tự nối: tokens, primitives, popup-shell, sections). CSS riêng của từng section (`section-library/**/gem.css`) KHÔNG nằm trong gói | `createGemThemeCssParts` |
14
14
  | `…/plugins/<key>` (`…/plugins/<key>.js`), `…/runtime-core` | Từ 0.8.0: mỗi plugin của runtime là một ES module, `export function mount(root = document, options?)` trả về `unmount()`, import không làm gì. Cùng dòng mã với `gem-runtime.js` (một bảng trong `runtime.ts`), chỉ khác: mọi `x.addEventListener(` thành `__gemListen(x, ` để unmount gỡ được. `runtime-core.js` là phần dùng chung: `mountPlugin`, `createGemApi`, `gemApi` | `runtime.ts`, `runtime-modules.ts`, `runtime-core.js` |
15
15
 
@@ -93,6 +93,44 @@ Từ 0.8.2 mỗi module có `.d.ts` cạnh nó (`mount(root?: ParentNode | null,
93
93
 
94
94
  ## Nhật ký thay đổi
95
95
 
96
+ - **0.14.0** (11/10/2026): CHỈ THÊM, store đang chạy không đổi pixel nào (không section nào của gemtheme, gemsite, gemboss
97
+ đọc các tên dưới đây; 30 mục kho GemFactory tự khai thang dùng tên riêng theo mục). Hai gap của bộ interaction "Gọn"
98
+ (Lily 08/10/2026) mà kho đang chép tay vào từng mục:
99
+ (1) **Thang chuyển động (#32).** `createGemTokenCss` luôn phát `--gem-motion-fast` 100ms, `-base` 180ms, `-slow` 260ms,
100
+ `--gem-ease-enter` / `--gem-ease-exit`, `--gem-motion-distance` 8px, `-distance-lg` 16px, `-stagger` 30ms, `-blur` 8px
101
+ (`GEM_MOTION_SCALE_DEFAULTS`, `gemMotionScaleCssVars`); plan đổi được qua `motion.fast|base|slow|enter|exit|distance|
102
+ distanceLg|stagger|blur`, viết kiểu nào cũng được (`Slow`, `distance_lg`): mặc định chỉ phát cho biến plan chưa khai.
103
+ Plan có `motion.duration` bằng 0 (style pack `motion_level: "none"`) nhận thang tĩnh. Kèm một khối
104
+ `@media (prefers-reduced-motion: reduce)` cùng selector đưa thời lượng về 1ms (sự kiện end vẫn bắn), quãng, nhịp so le
105
+ và độ nhoè về 0 (`GEM_MOTION_REDUCED`), mang `!important` ở cuối khai báo để stylesheet đến sau khai thang trên `:root`
106
+ không đè được lựa chọn của người dùng. Bộ kiểm CSS nhận các biến này là biến chuẩn. Bí danh motion giờ tra theo slug
107
+ (`motionCssVarName(key)`, dùng hàm này thay vì tra `motionCssVarAliases` theo đúng chữ):
108
+ `easing_soft`, `ambient-slow`, `Enter` ra đúng `--gem-ease-soft`, `--gem-ambient-slow`, `--gem-ease-enter` (trước đó ra
109
+ `--gem-motion-easing-soft`…, biến không rule nào đọc).
110
+ (2) **Ảnh hiện dần (#38).** `data-gem-img-in` trên `<img>` hoặc trên khung chứa (áp cho mọi ảnh bên trong). Plugin mới
111
+ `img-in` (critical, `gem:img-in:ready` mang số ảnh đã gắn) đánh dấu TỪNG ảnh (`data-gem-bound-img-in`) chưa tải xong lúc
112
+ gắn và đặt `data-gem-img-in-state="loading"`; tải xong `"in"`, hết hoạt ảnh `gem-img-in` thì gỡ (ảnh hiện lại từ
113
+ display:none không chạy lại), lỗi thì gỡ. Tải xong mà trạng thái nhoè không thực sự hiện (luật section ưu tiên cao hơn,
114
+ reveal đặt sau) hoặc người dùng giảm chuyển động thì gỡ thẳng, không chạy hoạt ảnh. Đứng ngoài: ảnh đã tải sẵn, và ảnh
115
+ section đã tự đặt opacity / filter / animation (ảnh phụ chờ rê chuột, hero tối, `data-gem-ambient`). Một listener pha
116
+ capture trên document, nên ảnh nhân bản lúc đang tải (marquee, slider vô hạn) không kẹt nhoè. Khung lồng khung gắn một
117
+ lần. CSS (`GEM_IMG_IN_CSS`, trong `createPrimitiveCss`) nằm trong `:where()` (độ ưu tiên 0) và chỉ khớp ảnh còn dấu, nên
118
+ unmount lúc ảnh đang tải không để ảnh kẹt nhoè; mount lại xoá trạng thái sót trên ảnh đã tải. Gắn MỘT lần: `gem-runtime.js`
119
+ không quét lại, ảnh do plugin khác chèn sau hiện như thường; bản module gắn nội dung mới bằng `mount(gốc mới)`, kể cả
120
+ khi vùng bật là tổ tiên ngoài gốc. Không JS thì ảnh hiện như thường. `runtimePluginKeySchema` (`@gemtheme/schemas`) có
121
+ thêm `img-in`; runtime đủ 23 plugin. Planner chưa lên lịch img-in (danh sách khoá của planner là bản chép tay, thiếu cả
122
+ tabs, hero-slideshow, reveal-on-scroll, offer-engine): store nhận plugin qua `gem-runtime.js` hoặc module.
123
+ - **0.13.0** (11/10/2026): kiểu nút **`gradient`** (`plan.global_settings.button_style`, `<body data-gem-button-style="gradient">`).
124
+ GemFactory Global Style đo 339 trang store hàng đầu: nút mua chính là gradient ở 8,4% (10,6% trang landing), 16/21 cái đo
125
+ được là MỘT tông đi từ sáng sang sẫm, và 14/21 có chữ dưới 4,5:1. Đầu kia của gradient là màu nhấn dời 25%
126
+ (`GEM_BUTTON_GRADIENT_SHIFT`) RA XA chữ nút: về đen khi màu nhấn tối hơn chữ, về trắng khi sáng hơn, nên chữ không bao giờ
127
+ kém tương phản hơn trên nền đặc. Viết bằng CSS (`color-mix` + `rgb(from …)`), đọc `--gem-color-accent` / `-accent-text`,
128
+ nên nút trong băng tối / băng accent theo bộ màu của băng; nằm trong `@supports`, trình duyệt không có cú pháp màu tương đối
129
+ giữ nút đặc như cũ. Bỏ qua nút link và secondary. Export mới: `gemButtonGradientEnd` (cùng phép tính bằng JS, cho phép đo),
130
+ `gemButtonGradientEndCss`, `GEM_BUTTON_GRADIENT_SHIFT`, `GEM_BUTTON_GRADIENT_PIVOT`. Schema `@gemtheme/schemas`: một
131
+ `buttonStyleSchema` cho cả ba chỗ trước đây tự liệt kê (cài đặt theme, design system plan, preset morph). Store không chọn
132
+ gradient không đổi gì, nên không cần edition mới. Biết mà chưa sửa: luật outline / soft cũng chạm nút link (chờ edition sau).
133
+
96
134
  - **0.12.1** (10/10/2026): hai ca biên của token nút, review gemsite #839 bắt khi gemsite chép phép đọc này để phát token
97
135
  cho store. (1) Bo trỏ một bậc bo khác (`radius.button: var(--gem-radius-pill)`) không bị nhân núm `--gem-style-radius`
98
136
  lần hai: bậc được trỏ tới đã nhân rồi (pill 1,4 thành 1,96×). Áp chung cho mọi thang qua `toScaledCssVars`; giá trị số
package/dist/SOURCE.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "@gemboss/gem-framework",
3
- "version": "0.12.1",
4
- "gemtheme_sha": "4ceacbe0c2ace7da410851fc5fe15589c1d87b77",
3
+ "version": "0.14.0",
4
+ "gemtheme_sha": "75954020da59dcca83cdae5676c0a9746e40ba4b",
5
5
  "gemtheme_dirty": false,
6
- "framework_version": "0.12.1"
6
+ "framework_version": "0.14.0"
7
7
  }
package/dist/editions.js CHANGED
@@ -71,11 +71,24 @@ var PHONE_TARGET_BY_KEY = Object.fromEntries(
71
71
  );
72
72
  var GEM_LINE_HEIGHT_TOKEN_KEYS = ["headingLineHeight", "bodyLineHeight"];
73
73
  var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
74
+ var GEM_MOTION_SCALE_DEFAULTS = {
75
+ fast: "100ms",
76
+ base: "180ms",
77
+ slow: "260ms",
78
+ enter: "cubic-bezier(.33, 1, .68, 1)",
79
+ exit: "cubic-bezier(.4, 0, 1, 1)",
80
+ distance: "8px",
81
+ distanceLg: "16px",
82
+ stagger: "30ms",
83
+ blur: "8px"
84
+ };
74
85
  var optionalGemTokenKeys = {
75
86
  typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
76
87
  spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
77
88
  layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
78
- radius: [...GEM_BUTTON_TOKEN_KEYS.radius]
89
+ radius: [...GEM_BUTTON_TOKEN_KEYS.radius],
90
+ // Khoá motion ngoài duration / easing: nền ambient (đã phát từ trước nhưng chưa khai ở đây) và thang "Gọn" (#32).
91
+ motion: ["easingSoft", "ambientSlow", "ambientMid", "ambientFast", "ambientAmp", ...Object.keys(GEM_MOTION_SCALE_DEFAULTS)]
79
92
  };
80
93
  var GEM_HEADING_SIZE_CSS_VARS = [
81
94
  "--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
@@ -83,6 +96,18 @@ var GEM_HEADING_SIZE_CSS_VARS = [
83
96
  "--gem-section-heading-size-mobile: min(var(--gem-section-heading-size), var(--gem-heading-phone-cap, clamp(1.75rem, 9vw, 2.75rem)));",
84
97
  "--gem-hero-heading-size-mobile: min(var(--gem-hero-heading-size), var(--gem-heading-phone-cap, clamp(1.75rem, 9vw, 2.75rem)));"
85
98
  ];
99
+ var motionCssVarAliases = {
100
+ easingSoft: "--gem-ease-soft",
101
+ enter: "--gem-ease-enter",
102
+ exit: "--gem-ease-exit",
103
+ ambientSlow: "--gem-ambient-slow",
104
+ ambientMid: "--gem-ambient-mid",
105
+ ambientFast: "--gem-ambient-fast",
106
+ ambientAmp: "--gem-ambient-amp"
107
+ };
108
+ var motionCssVarAliasBySlug = Object.fromEntries(
109
+ Object.entries(motionCssVarAliases).map(([key, name]) => [slugify(key), name])
110
+ );
86
111
  function slugify(value) {
87
112
  return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
88
113
  }
@@ -37,6 +37,15 @@
37
37
  --gem-ambient-mid: 7s;
38
38
  --gem-ambient-fast: 5.5s;
39
39
  --gem-ambient-amp: 7px;
40
+ --gem-motion-fast: 100ms;
41
+ --gem-motion-base: 180ms;
42
+ --gem-motion-slow: 260ms;
43
+ --gem-ease-enter: cubic-bezier(.33, 1, .68, 1);
44
+ --gem-ease-exit: cubic-bezier(.4, 0, 1, 1);
45
+ --gem-motion-distance: 8px;
46
+ --gem-motion-distance-lg: 16px;
47
+ --gem-motion-stagger: 30ms;
48
+ --gem-motion-blur: 8px;
40
49
  --gem-layout-page: 1180px;
41
50
  --gem-layout-gutter: 32px;
42
51
  --gem-layout-section: 64px;
@@ -68,6 +77,17 @@
68
77
  --gem-border-subtle: calc(1px * var(--gem-style-border, 1)) solid var(--gem-color-border);
69
78
  --gem-border-strong: calc(1px * var(--gem-style-border, 1)) solid color-mix(in srgb, var(--gem-color-text) 28%, transparent);
70
79
  }
80
+ @media (prefers-reduced-motion: reduce) {
81
+ :root {
82
+ --gem-motion-fast: 1ms !important;
83
+ --gem-motion-base: 1ms !important;
84
+ --gem-motion-slow: 1ms !important;
85
+ --gem-motion-distance: 0px !important;
86
+ --gem-motion-distance-lg: 0px !important;
87
+ --gem-motion-stagger: 0ms !important;
88
+ --gem-motion-blur: 0px !important;
89
+ }
90
+ }
71
91
 
72
92
  *, *::before, *::after {
73
93
  box-sizing: border-box;
@@ -214,6 +234,16 @@ a {
214
234
  border-color: transparent;
215
235
  color: var(--gem-color-text);
216
236
  }
237
+ @supports (color: rgb(from color-mix(in srgb, red 50%, rgb(from blue calc(255 - r) g b)) clamp(0, (r - 127.5) * 1000, 255) g b)) {
238
+ [data-gem-button-style='gradient'] .gem-button:not(.gem-button--secondary, .gem-button--link),
239
+ [data-gem-button-style='gradient'] .gem-button--primary {
240
+ background-color: var(--gem-color-accent);
241
+ background-image: linear-gradient(135deg, var(--gem-color-accent), color-mix(in srgb, var(--gem-color-accent) 75%, rgb(from color-mix(in srgb, var(--gem-color-accent) 50%, rgb(from var(--gem-color-accent-text, var(--gem-color-surface)) calc(255 - r) calc(255 - g) calc(255 - b))) clamp(0, (0.2126 * r + 0.7152 * g + 0.0722 * b - 127.5) * 1000, 255) clamp(0, (0.2126 * r + 0.7152 * g + 0.0722 * b - 127.5) * 1000, 255) clamp(0, (0.2126 * r + 0.7152 * g + 0.0722 * b - 127.5) * 1000, 255) / 1)));
242
+ background-origin: border-box;
243
+ border-color: transparent;
244
+ color: var(--gem-color-accent-text, var(--gem-color-surface));
245
+ }
246
+ }
217
247
  .gem-card {
218
248
  border: var(--gem-border-subtle, 1px solid var(--gem-color-border));
219
249
  border-radius: var(--gem-card-radius, var(--gem-radius-md));
@@ -1090,6 +1120,9 @@ a {
1090
1120
  [data-gem-reveal='zoom'][data-gem-reveal-state='ready'] { opacity: 0; transform: scale(0.94); }
1091
1121
  [data-gem-reveal='blur'][data-gem-reveal-state='ready'] { opacity: 0; filter: blur(10px); transform: none; }
1092
1122
  @media (prefers-reduced-motion: reduce) { [data-gem-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; } }
1123
+ :where(img[data-gem-bound-img-in][data-gem-img-in-state='loading']) { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; }
1124
+ @media (prefers-reduced-motion: no-preference) { :where(img[data-gem-bound-img-in][data-gem-img-in-state='in']) { animation: gem-img-in var(--gem-motion-slow, 260ms) var(--gem-ease-enter, cubic-bezier(.33, 1, .68, 1)); } }
1125
+ @keyframes gem-img-in { from { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; } }
1093
1126
  @keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }
1094
1127
  @keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
1095
1128
  @keyframes gem-marquee-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@@ -1,5 +1,5 @@
1
1
  (() => {
2
- const config = {"planId":"gem-framework-package","base":"gem-native","dawnAdapter":false,"plugins":[{"key":"cart-drawer","loadStrategy":"interaction","dependsOn":[]},{"key":"mobile-menu","loadStrategy":"interaction","dependsOn":[]},{"key":"quantity-input","loadStrategy":"interaction","dependsOn":[]},{"key":"variant-selects","loadStrategy":"interaction","dependsOn":[]},{"key":"predictive-search","loadStrategy":"critical","dependsOn":[]},{"key":"localization","loadStrategy":"interaction","dependsOn":[]},{"key":"disclosure","loadStrategy":"interaction","dependsOn":[]},{"key":"tabs","loadStrategy":"interaction","dependsOn":[]},{"key":"media-gallery","loadStrategy":"viewport","dependsOn":[]},{"key":"carousel","loadStrategy":"viewport","dependsOn":[]},{"key":"hero-slideshow","loadStrategy":"idle","dependsOn":[]},{"key":"quick-view","loadStrategy":"interaction","dependsOn":[]},{"key":"promo-popup","loadStrategy":"idle","dependsOn":[]},{"key":"before_after","loadStrategy":"viewport","dependsOn":[]},{"key":"runtime-slot-loader","loadStrategy":"idle","dependsOn":[]},{"key":"sticky-atc","loadStrategy":"viewport","dependsOn":[]},{"key":"recently-viewed","loadStrategy":"idle","dependsOn":[]},{"key":"countdown","loadStrategy":"viewport","dependsOn":[]},{"key":"reveal-on-scroll","loadStrategy":"viewport","dependsOn":[]},{"key":"seasonal-effects","loadStrategy":"idle","dependsOn":[]},{"key":"context-adapter","loadStrategy":"idle","dependsOn":[]},{"key":"offer-engine","loadStrategy":"interaction","dependsOn":[]}]};
2
+ const config = {"planId":"gem-framework-package","base":"gem-native","dawnAdapter":false,"plugins":[{"key":"cart-drawer","loadStrategy":"interaction","dependsOn":[]},{"key":"mobile-menu","loadStrategy":"interaction","dependsOn":[]},{"key":"quantity-input","loadStrategy":"interaction","dependsOn":[]},{"key":"variant-selects","loadStrategy":"interaction","dependsOn":[]},{"key":"predictive-search","loadStrategy":"critical","dependsOn":[]},{"key":"localization","loadStrategy":"interaction","dependsOn":[]},{"key":"disclosure","loadStrategy":"interaction","dependsOn":[]},{"key":"tabs","loadStrategy":"interaction","dependsOn":[]},{"key":"media-gallery","loadStrategy":"viewport","dependsOn":[]},{"key":"carousel","loadStrategy":"viewport","dependsOn":[]},{"key":"hero-slideshow","loadStrategy":"idle","dependsOn":[]},{"key":"quick-view","loadStrategy":"interaction","dependsOn":[]},{"key":"promo-popup","loadStrategy":"idle","dependsOn":[]},{"key":"before_after","loadStrategy":"viewport","dependsOn":[]},{"key":"runtime-slot-loader","loadStrategy":"idle","dependsOn":[]},{"key":"sticky-atc","loadStrategy":"viewport","dependsOn":[]},{"key":"recently-viewed","loadStrategy":"idle","dependsOn":[]},{"key":"countdown","loadStrategy":"viewport","dependsOn":[]},{"key":"reveal-on-scroll","loadStrategy":"viewport","dependsOn":[]},{"key":"img-in","loadStrategy":"critical","dependsOn":[]},{"key":"seasonal-effects","loadStrategy":"idle","dependsOn":[]},{"key":"context-adapter","loadStrategy":"idle","dependsOn":[]},{"key":"offer-engine","loadStrategy":"interaction","dependsOn":[]}]};
3
3
  const target = window;
4
4
  const existing = target.GemTheme || {};
5
5
  const listeners = existing.listeners || {};
@@ -914,6 +914,37 @@
914
914
  });
915
915
  api.emit('gem:reveal:ready', { source: 'gem-runtime', count: bound });
916
916
  });
917
+ registerPlugin('img-in', ({ api }) => {
918
+ const STATE = 'data-gem-img-in-state';
919
+ const BOUND = 'data-gem-bound-img-in';
920
+ const reduce = typeof target.matchMedia === 'function' && target.matchMedia('(prefers-reduced-motion: reduce)').matches;
921
+ const style = (img) => (typeof target.getComputedStyle === 'function' ? target.getComputedStyle(img) : null);
922
+ const owned = (s) => Boolean(s && ((s.opacity && s.opacity !== '1') || (s.filter && s.filter !== 'none') || (s.animationName && s.animationName !== 'none')));
923
+ const overridden = (s) => Boolean(s && ((s.opacity && s.opacity !== '0.6') || (s.filter && s.filter.indexOf('blur') === -1)));
924
+ const settle = (event) => {
925
+ const img = event.target;
926
+ if (!img || img.tagName !== 'IMG' || !img.hasAttribute(BOUND)) return;
927
+ const state = img.getAttribute(STATE);
928
+ if (event.type === 'load' || event.type === 'error') {
929
+ if (state !== 'loading') return;
930
+ if (event.type === 'load' && !reduce && !overridden(style(img))) img.setAttribute(STATE, 'in');
931
+ else img.removeAttribute(STATE);
932
+ return;
933
+ }
934
+ if (state === 'in' && event.animationName === 'gem-img-in') img.removeAttribute(STATE);
935
+ };
936
+ ['load', 'error', 'animationend', 'animationcancel'].forEach((type) => document.addEventListener(type, settle, true));
937
+ const candidates = [];
938
+ document.querySelectorAll('img').forEach((img) => {
939
+ if (!img.closest('[data-gem-img-in]')) return;
940
+ if (img.complete) { if (!img.hasAttribute(BOUND)) img.removeAttribute(STATE); return; }
941
+ if (mark(img, 'img-in')) candidates.push(img);
942
+ });
943
+ const own = candidates.map((img) => owned(style(img)));
944
+ let bound = 0;
945
+ candidates.forEach((img, i) => { if (own[i]) return; img.setAttribute(STATE, 'loading'); bound += 1; });
946
+ api.emit('gem:img-in:ready', { source: 'gem-runtime', count: bound });
947
+ });
917
948
  registerPlugin('recently-viewed', ({ api }) => {
918
949
  const storageKey = 'gem:recently-viewed-products';
919
950
  const readItems = () => {
package/dist/index.js CHANGED
@@ -259,9 +259,28 @@ function aaLabelColor(preferred, fill, fallbacks = []) {
259
259
  if (pass) return pass;
260
260
  return candidates.reduce((a, b) => hexContrastRatio(b, ground) > hexContrastRatio(a, ground) ? b : a, preferred);
261
261
  }
262
+ var GEM_BUTTON_GRADIENT_SHIFT = 0.25;
263
+ var GEM_BUTTON_GRADIENT_PIVOT = 127.5;
264
+ var weighted = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
265
+ var target = (w) => Math.min(255, Math.max(0, (w - GEM_BUTTON_GRADIENT_PIVOT) * 1e3));
266
+ function gemButtonGradientEnd(accent, accentText) {
267
+ const a = normalizeHex(accent);
268
+ const t = normalizeHex(accentText);
269
+ if (!a || !t) return void 0;
270
+ const ch = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
271
+ const [ar, ag, ab] = ch(a);
272
+ const [tr, tg, tb] = ch(t);
273
+ const gray = Math.round(target(weighted((ar + 255 - tr) / 2, (ag + 255 - tg) / 2, (ab + 255 - tb) / 2))).toString(16).padStart(2, "0");
274
+ return mixHex(a, `#${gray}${gray}${gray}`, GEM_BUTTON_GRADIENT_SHIFT);
275
+ }
276
+ function gemButtonGradientEndCss(accent = "var(--gem-color-accent)", accentText = "var(--gem-color-accent-text, var(--gem-color-surface))") {
277
+ const t = `clamp(0, (0.2126 * r + 0.7152 * g + 0.0722 * b - ${GEM_BUTTON_GRADIENT_PIVOT}) * 1000, 255)`;
278
+ const probe = `color-mix(in srgb, ${accent} 50%, rgb(from ${accentText} calc(255 - r) calc(255 - g) calc(255 - b)))`;
279
+ return `color-mix(in srgb, ${accent} ${100 - GEM_BUTTON_GRADIENT_SHIFT * 100}%, rgb(from ${probe} ${t} ${t} ${t} / 1))`;
280
+ }
262
281
 
263
282
  // ../../packages/gem-framework/src/tokens.ts
264
- var gemFrameworkVersion = "0.12.1";
283
+ var gemFrameworkVersion = "0.14.0";
265
284
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
266
285
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
267
286
  var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
@@ -464,13 +483,13 @@ function readPhoneTokens(tokens) {
464
483
  for (const group of ["spacing", "layout"]) {
465
484
  const ownGroup = group === "spacing" ? /var\(\s*--gem-space-/i : /var\(\s*--gem-layout-/i;
466
485
  for (const [key, raw] of Object.entries(tokens?.[group] ?? {})) {
467
- const target = gemPhoneTokenTarget(group, key);
468
- if (target == null) continue;
486
+ const target2 = gemPhoneTokenTarget(group, key);
487
+ if (target2 == null) continue;
469
488
  const v = stripImportant(String(raw ?? "")).trim();
470
489
  const kind = gemSpaceMdKind(v);
471
490
  if (kind === "absent") continue;
472
- if (kind === "zero") out[group][target] = "0px";
473
- else if (kind === "length" && !ownGroup.test(v)) out[group][target] = v;
491
+ if (kind === "zero") out[group][target2] = "0px";
492
+ else if (kind === "length" && !ownGroup.test(v)) out[group][target2] = v;
474
493
  else out.invalid.push({ path: `${group}.${key}`, value: raw, why: kind === "length" ? `it reads a ${group} variable, which re-declared on :root is a cycle` : "not a length (a number needs a unit, e.g. 70px)" });
475
494
  }
476
495
  }
@@ -482,11 +501,41 @@ var isLineHeightKey = (k) => GEM_LINE_HEIGHT_TOKEN_KEYS.some((l) => tokenKeyNorm
482
501
  var badLineHeight = (k, v) => isLineHeightKey(k) && !LINE_HEIGHT_SHAPE.test(stripImportant(String(v ?? "")).trim());
483
502
  var desktopEntries = (group, values) => values ? Object.fromEntries(Object.entries(values).filter(([key]) => gemPhoneTokenTarget(group, key) == null)) : values;
484
503
  var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
504
+ var GEM_MOTION_SCALE_DEFAULTS = {
505
+ fast: "100ms",
506
+ base: "180ms",
507
+ slow: "260ms",
508
+ enter: "cubic-bezier(.33, 1, .68, 1)",
509
+ exit: "cubic-bezier(.4, 0, 1, 1)",
510
+ distance: "8px",
511
+ distanceLg: "16px",
512
+ stagger: "30ms",
513
+ blur: "8px"
514
+ };
515
+ var GEM_MOTION_REDUCED = {
516
+ fast: "1ms",
517
+ base: "1ms",
518
+ slow: "1ms",
519
+ distance: "0px",
520
+ distanceLg: "0px",
521
+ stagger: "0ms",
522
+ blur: "0px"
523
+ };
524
+ var cssVarName = (line) => line.slice(0, line.indexOf(":")).trim();
525
+ function gemMotionScaleCssVars(motion) {
526
+ const declared = new Set(toMotionCssVars(motion ?? {}).map(cssVarName));
527
+ const duration = Object.entries(motion ?? {}).find(([key]) => motionCssVarName(key) === "--gem-motion-duration")?.[1];
528
+ const still = /^0+(?:\.0+)?(?:m?s)?$/i.test(String(duration ?? "").trim());
529
+ const scale = still ? { ...GEM_MOTION_SCALE_DEFAULTS, ...GEM_MOTION_REDUCED } : GEM_MOTION_SCALE_DEFAULTS;
530
+ return toMotionCssVars(scale).filter((line) => !declared.has(cssVarName(line)));
531
+ }
485
532
  var optionalGemTokenKeys = {
486
533
  typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
487
534
  spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
488
535
  layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
489
- radius: [...GEM_BUTTON_TOKEN_KEYS.radius]
536
+ radius: [...GEM_BUTTON_TOKEN_KEYS.radius],
537
+ // Khoá motion ngoài duration / easing: nền ambient (đã phát từ trước nhưng chưa khai ở đây) và thang "Gọn" (#32).
538
+ motion: ["easingSoft", "ambientSlow", "ambientMid", "ambientFast", "ambientAmp", ...Object.keys(GEM_MOTION_SCALE_DEFAULTS)]
490
539
  };
491
540
  var GEM_HEADING_SIZE_CSS_VARS = [
492
541
  "--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
@@ -643,6 +692,8 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
643
692
  // and marquee primitives consume; unrecognised keys fall back to the --gem-motion-* namespace,
644
693
  // so duration/easing keep emitting --gem-motion-duration/easing exactly as before (back-compat).
645
694
  ...toMotionCssVars(tokens.motion),
695
+ // Thang "Gọn" (#32): khoá plan không khai thì phát mặc định, sau các dòng của plan để thứ tự cũ không đổi.
696
+ ...gemMotionScaleCssVars(tokens.motion),
646
697
  ...toCssVars("layout", desktopEntries("layout", tokens.layout)),
647
698
  ...phone.layout.section ? [`--gem-layout-section-mobile: ${phone.layout.section};`] : [],
648
699
  ...toCssVars("component", tokens.component),
@@ -675,6 +726,14 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
675
726
  "}",
676
727
  ...bandBorders.length ? [`:where(${bandScope}) {`, ...bandBorders.map((line) => ` ${line}`), "}"] : [],
677
728
  ...mobile.length ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ...mobile.map((line) => ` ${line}`), " }", "}"] : [],
729
+ "@media (prefers-reduced-motion: reduce) {",
730
+ ` ${selector} {`,
731
+ // !important ở CUỐI khai báo (hợp lệ, khác cờ lọt vào giữa giá trị): stylesheet đến sau khai thang trên :root
732
+ // (gemsite theme-plan-style) không đè được lựa chọn giảm chuyển động của người dùng. Section tự khai thang trên chính
733
+ // nó vẫn giữ giá trị của nó, như mọi biến kế thừa.
734
+ ...toMotionCssVars(GEM_MOTION_REDUCED).map((line) => ` ${line.replace(/;$/, " !important;")}`),
735
+ " }",
736
+ "}",
678
737
  ...formBlock ? [formBlock] : [],
679
738
  ...buttonCase ? [buttonCase] : []
680
739
  ].join("\n");
@@ -723,16 +782,22 @@ function toCssVars(namespace, values) {
723
782
  }
724
783
  var motionCssVarAliases = {
725
784
  easingSoft: "--gem-ease-soft",
785
+ enter: "--gem-ease-enter",
786
+ exit: "--gem-ease-exit",
726
787
  ambientSlow: "--gem-ambient-slow",
727
788
  ambientMid: "--gem-ambient-mid",
728
789
  ambientFast: "--gem-ambient-fast",
729
790
  ambientAmp: "--gem-ambient-amp"
730
791
  };
792
+ var motionCssVarAliasBySlug = Object.fromEntries(
793
+ Object.entries(motionCssVarAliases).map(([key, name]) => [slugify(key), name])
794
+ );
795
+ function motionCssVarName(key) {
796
+ return motionCssVarAliasBySlug[slugify(key)] ?? `--gem-motion-${slugify(key)}`;
797
+ }
731
798
  function toMotionCssVars(values) {
732
- return Object.entries(values).map(([key, value]) => {
733
- const name = motionCssVarAliases[key] ?? `--gem-motion-${slugify(key)}`;
734
- return `${name}: ${value};`;
735
- });
799
+ if (!values) return [];
800
+ return Object.entries(values).map(([key, value]) => `${motionCssVarName(key)}: ${value};`);
736
801
  }
737
802
  function toScaledCssVars(namespace, values, knob) {
738
803
  if (!values) return [];
@@ -1181,6 +1246,11 @@ var gemFrameworkRuntimePlugins = [
1181
1246
  load_strategy: "viewport",
1182
1247
  events: ["gem:reveal:ready", "gem:reveal:in"]
1183
1248
  },
1249
+ {
1250
+ key: "img-in",
1251
+ load_strategy: "critical",
1252
+ events: ["gem:img-in:ready"]
1253
+ },
1184
1254
  {
1185
1255
  key: "seasonal-effects",
1186
1256
  load_strategy: "idle",
@@ -1507,6 +1577,12 @@ var GEM_REVEAL_CSS_LINES = [
1507
1577
  "@media (prefers-reduced-motion: reduce) { [data-gem-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; } }"
1508
1578
  ];
1509
1579
  var GEM_REVEAL_CSS = GEM_REVEAL_CSS_LINES.join("\n");
1580
+ var GEM_IMG_IN_CSS_LINES = [
1581
+ ":where(img[data-gem-bound-img-in][data-gem-img-in-state='loading']) { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; }",
1582
+ "@media (prefers-reduced-motion: no-preference) { :where(img[data-gem-bound-img-in][data-gem-img-in-state='in']) { animation: gem-img-in var(--gem-motion-slow, 260ms) var(--gem-ease-enter, cubic-bezier(.33, 1, .68, 1)); } }",
1583
+ "@keyframes gem-img-in { from { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; } }"
1584
+ ];
1585
+ var GEM_IMG_IN_CSS = GEM_IMG_IN_CSS_LINES.join("\n");
1510
1586
  var GEM_AMBIENT_CSS_LINES = [
1511
1587
  "@keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }",
1512
1588
  "@keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }",
@@ -1767,6 +1843,9 @@ function createPrimitiveCss() {
1767
1843
  // and `.gem-button--soft` following button_style on the published storefront but NOT in the
1768
1844
  // preview, which is the drift this rule exists to remove. `--primary` is named too so the
1769
1845
  // modifier keeps winning where both could apply.
1846
+ // KNOWN (11/10/2026): these two also reach `.gem-button--link` (soft paints its tint behind a link-styled button,
1847
+ // outline recolours it). The gradient rule below leaves the link out; fixing these changes stores that did not ask,
1848
+ // so it waits for the next framework edition (editions.ts).
1770
1849
  "[data-gem-button-style='outline'] .gem-button:not(.gem-button--secondary),",
1771
1850
  "[data-gem-button-style='outline'] .gem-button--primary {",
1772
1851
  " background: transparent;",
@@ -1778,6 +1857,28 @@ function createPrimitiveCss() {
1778
1857
  " border-color: transparent;",
1779
1858
  " color: var(--gem-color-text);",
1780
1859
  "}",
1860
+ // `gradient` (0.13.0). GemFactory Global Style, 339 top-store pages: the main CTA is a gradient on 8.4% (10.6% of
1861
+ // landing pages), and 16 of the 21 measured are ONE hue going lighter → darker; 14 of the 21 drew their text under
1862
+ // 4.5:1. The far stop is the accent moved GEM_BUTTON_GRADIENT_SHIFT AWAY from the button text: toward black when the
1863
+ // accent is the darker of the two, toward white when it is the lighter (gemButtonGradientEnd is the same sum in JS;
1864
+ // contrast.ts has the two versions that failed), so the text only gains contrast across the button. It reads
1865
+ // --gem-color-accent / -accent-text, which a dark or accent band points at its own scheme, so a gradient button inside
1866
+ // a band follows the band. The text colour is set here because a bare `.gem-button` sets none. The link variant is
1867
+ // left out (it is text). Behind @supports, and in LONGHANDS: @supports only proves the syntax parses, and a value that
1868
+ // still fails at computed time (a var() that does not resolve) would take the whole `background` shorthand to
1869
+ // transparent under white text (review of #737, reproduced). As longhands only the image drops, over the accent.
1870
+ // Near black or white the two stops all but meet (#121212 → #0e0e0e): moving toward the text would break the AA
1871
+ // promise, so such a palette draws a flat button, and GemFactory's preset gate says so.
1872
+ "@supports (color: rgb(from color-mix(in srgb, red 50%, rgb(from blue calc(255 - r) g b)) clamp(0, (r - 127.5) * 1000, 255) g b)) {",
1873
+ " [data-gem-button-style='gradient'] .gem-button:not(.gem-button--secondary, .gem-button--link),",
1874
+ " [data-gem-button-style='gradient'] .gem-button--primary {",
1875
+ " background-color: var(--gem-color-accent);",
1876
+ ` background-image: linear-gradient(135deg, var(--gem-color-accent), ${gemButtonGradientEndCss()});`,
1877
+ " background-origin: border-box;",
1878
+ " border-color: transparent;",
1879
+ " color: var(--gem-color-accent-text, var(--gem-color-surface));",
1880
+ " }",
1881
+ "}",
1781
1882
  ".gem-card {",
1782
1883
  " border: var(--gem-border-subtle, 1px solid var(--gem-color-border));",
1783
1884
  " border-radius: var(--gem-card-radius, var(--gem-radius-md));",
@@ -2653,6 +2754,7 @@ function createPrimitiveCss() {
2653
2754
  " opacity: 1;",
2654
2755
  "}",
2655
2756
  ...GEM_REVEAL_CSS_LINES,
2757
+ ...GEM_IMG_IN_CSS_LINES,
2656
2758
  ...GEM_AMBIENT_CSS_LINES,
2657
2759
  ...GEM_DIVIDER_CSS_LINES,
2658
2760
  ...GEM_CARD_CSS_LINES,
@@ -4561,6 +4663,7 @@ var runtimePluginReadyEvents = {
4561
4663
  "recently-viewed": "gem:recently-viewed:ready",
4562
4664
  countdown: "gem:countdown:ready",
4563
4665
  "reveal-on-scroll": "gem:reveal:ready",
4666
+ "img-in": "gem:img-in:ready",
4564
4667
  "seasonal-effects": "gem:seasonal:ready",
4565
4668
  "context-adapter": "gem:context:ready",
4566
4669
  "offer-engine": "gem:offer:ready"
@@ -5530,6 +5633,39 @@ var revealOnScrollPluginRegistrationLines = [
5530
5633
  " api.emit('gem:reveal:ready', { source: 'gem-runtime', count: bound });",
5531
5634
  " });"
5532
5635
  ];
5636
+ var imgInPluginRegistrationLines = [
5637
+ " registerPlugin('img-in', ({ api }) => {",
5638
+ " const STATE = 'data-gem-img-in-state';",
5639
+ " const BOUND = 'data-gem-bound-img-in';",
5640
+ " const reduce = typeof target.matchMedia === 'function' && target.matchMedia('(prefers-reduced-motion: reduce)').matches;",
5641
+ " const style = (img) => (typeof target.getComputedStyle === 'function' ? target.getComputedStyle(img) : null);",
5642
+ " const owned = (s) => Boolean(s && ((s.opacity && s.opacity !== '1') || (s.filter && s.filter !== 'none') || (s.animationName && s.animationName !== 'none')));",
5643
+ " const overridden = (s) => Boolean(s && ((s.opacity && s.opacity !== '0.6') || (s.filter && s.filter.indexOf('blur') === -1)));",
5644
+ " const settle = (event) => {",
5645
+ " const img = event.target;",
5646
+ " if (!img || img.tagName !== 'IMG' || !img.hasAttribute(BOUND)) return;",
5647
+ " const state = img.getAttribute(STATE);",
5648
+ " if (event.type === 'load' || event.type === 'error') {",
5649
+ " if (state !== 'loading') return;",
5650
+ " if (event.type === 'load' && !reduce && !overridden(style(img))) img.setAttribute(STATE, 'in');",
5651
+ " else img.removeAttribute(STATE);",
5652
+ " return;",
5653
+ " }",
5654
+ " if (state === 'in' && event.animationName === 'gem-img-in') img.removeAttribute(STATE);",
5655
+ " };",
5656
+ " ['load', 'error', 'animationend', 'animationcancel'].forEach((type) => document.addEventListener(type, settle, true));",
5657
+ " const candidates = [];",
5658
+ " document.querySelectorAll('img').forEach((img) => {",
5659
+ " if (!img.closest('[data-gem-img-in]')) return;",
5660
+ " if (img.complete) { if (!img.hasAttribute(BOUND)) img.removeAttribute(STATE); return; }",
5661
+ " if (mark(img, 'img-in')) candidates.push(img);",
5662
+ " });",
5663
+ " const own = candidates.map((img) => owned(style(img)));",
5664
+ " let bound = 0;",
5665
+ " candidates.forEach((img, i) => { if (own[i]) return; img.setAttribute(STATE, 'loading'); bound += 1; });",
5666
+ " api.emit('gem:img-in:ready', { source: 'gem-runtime', count: bound });",
5667
+ " });"
5668
+ ];
5533
5669
  var recentlyViewedPluginRegistrationLines = [
5534
5670
  " registerPlugin('recently-viewed', ({ api }) => {",
5535
5671
  " const storageKey = 'gem:recently-viewed-products';",
@@ -5626,6 +5762,7 @@ var runtimePluginSourceLines = [
5626
5762
  ["before_after", beforeAfterPluginRegistrationLines],
5627
5763
  ["countdown", countdownPluginRegistrationLines],
5628
5764
  ["reveal-on-scroll", revealOnScrollPluginRegistrationLines],
5765
+ ["img-in", imgInPluginRegistrationLines],
5629
5766
  ["recently-viewed", recentlyViewedPluginRegistrationLines],
5630
5767
  ["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
5631
5768
  ["offer-engine", offerEnginePluginRegistrationLines]
@@ -5927,6 +6064,8 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
5927
6064
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
5928
6065
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
5929
6066
  GEM_FOCUS_OFFSET_CSS_VAR.split(":")[0].trim(),
6067
+ // Thang chuyển động "Gọn" (tokens.ts GEM_MOTION_SCALE_DEFAULTS, #32): luôn phát, section đọc được.
6068
+ ...toMotionCssVars(GEM_MOTION_SCALE_DEFAULTS).map((line) => line.split(":")[0].trim()),
5930
6069
  // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
5931
6070
  ...GEM_DERIVED_SPACE_CSS_VARIABLES,
5932
6071
  // Extension points a section SETS (gemtheme #650): icon size, drawer slide distance.
@@ -6389,9 +6528,9 @@ function renderHeroElementBlocks(section) {
6389
6528
  const s = b?.settings ?? {};
6390
6529
  if (b?.type === "countdown") {
6391
6530
  const label = str(s.label) ? `<p class="gem-hero__countdown-label">${escapeHtml(str(s.label))}</p>` : "";
6392
- const target = escapeAttribute(str(s.end_date));
6531
+ const target2 = escapeAttribute(str(s.end_date));
6393
6532
  const unit = (n, l) => `<div class="gem-countdown__unit"><span class="gem-countdown__num" data-gem-cd-${n}>00</span><span class="gem-countdown__label">${l}</span></div>`;
6394
- return `<div class="gem-hero__countdown">${label}<div class="gem-countdown" data-gem-countdown data-gem-countdown-target="${target}" role="timer" aria-label="Offer countdown">${unit("days", "Days")}<div class="gem-countdown__sep">:</div>${unit("hours", "Hrs")}<div class="gem-countdown__sep">:</div>${unit("mins", "Min")}<div class="gem-countdown__sep">:</div>${unit("secs", "Sec")}</div></div>`;
6533
+ return `<div class="gem-hero__countdown">${label}<div class="gem-countdown" data-gem-countdown data-gem-countdown-target="${target2}" role="timer" aria-label="Offer countdown">${unit("days", "Days")}<div class="gem-countdown__sep">:</div>${unit("hours", "Hrs")}<div class="gem-countdown__sep">:</div>${unit("mins", "Min")}<div class="gem-countdown__sep">:</div>${unit("secs", "Sec")}</div></div>`;
6395
6534
  }
6396
6535
  if (b?.type === "trust_badges") {
6397
6536
  const items = ["badge_1", "badge_2", "badge_3"].map((k) => str(s[k])).filter(Boolean).map((t) => `<li class="gem-hero__trust-item"><svg class="gem-hero__trust-check" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>${escapeHtml(t)}</li>`).join("");
@@ -7255,6 +7394,8 @@ export {
7255
7394
  GEM_AMBIENT_CSS,
7256
7395
  GEM_BAND_SCHEMES,
7257
7396
  GEM_BAND_SCHEME_FALLBACK_CSS,
7397
+ GEM_BUTTON_GRADIENT_PIVOT,
7398
+ GEM_BUTTON_GRADIENT_SHIFT,
7258
7399
  GEM_BUTTON_TOKEN_KEYS,
7259
7400
  GEM_BUTTON_TOKEN_SHAPES,
7260
7401
  GEM_COUNTDOWN_ENDED_CSS,
@@ -7270,7 +7411,10 @@ export {
7270
7411
  GEM_FRAMEWORK_EDITIONS,
7271
7412
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
7272
7413
  GEM_HEADING_SIZE_CSS_VARS,
7414
+ GEM_IMG_IN_CSS,
7273
7415
  GEM_LINE_HEIGHT_TOKEN_KEYS,
7416
+ GEM_MOTION_REDUCED,
7417
+ GEM_MOTION_SCALE_DEFAULTS,
7274
7418
  GEM_PART_CLASS,
7275
7419
  GEM_POPUP_SHELL_CSS,
7276
7420
  GEM_REVEAL_CSS,
@@ -7310,6 +7454,8 @@ export {
7310
7454
  gemBandSchemeCssVars,
7311
7455
  gemBandSchemeDeclarations,
7312
7456
  gemButtonCaseCss,
7457
+ gemButtonGradientEnd,
7458
+ gemButtonGradientEndCss,
7313
7459
  gemFormAttributes,
7314
7460
  gemFormCssBlock,
7315
7461
  gemFormCssVars,
@@ -7323,6 +7469,7 @@ export {
7323
7469
  gemFrameworkValidationRules,
7324
7470
  gemFrameworkVersion,
7325
7471
  gemMobileTokenCssVars,
7472
+ gemMotionScaleCssVars,
7326
7473
  gemPartClass,
7327
7474
  gemPhoneTokenTarget,
7328
7475
  gemPlanShapeKnobs,
@@ -7336,6 +7483,7 @@ export {
7336
7483
  modernMineralFamilyLineage,
7337
7484
  modernMineralHomepageLineageSections,
7338
7485
  motionCssVarAliases,
7486
+ motionCssVarName,
7339
7487
  normalizeGemTokens,
7340
7488
  normalizeHex,
7341
7489
  offerEngineRuntimeScript,
@@ -0,0 +1,9 @@
1
+ // @gemboss/gem-framework/plugins/img-in: types of the ES module next to this file. Generated; do not edit here.
2
+ import type { MountOptions, Unmount } from "../runtime-core.js";
3
+
4
+ export type { MountOptions, Unmount };
5
+ export declare const key: "img-in";
6
+ export declare const readyEvent: "gem:img-in:ready";
7
+ /** Bind every "img-in" hook inside root (default: the whole document). Returns unmount(). Mounting the same root twice binds once. */
8
+ export declare function mount(root?: ParentNode | null, options?: MountOptions): Unmount;
9
+ export default mount;
@@ -0,0 +1,47 @@
1
+ // @gemboss/gem-framework/plugins/img-in: the gem-runtime plugin "img-in" as an ES module. Generated from
2
+ // packages/gem-framework/src/runtime.ts, the same lines gem-runtime.js carries; do not edit here.
3
+ import { mountPlugin } from "../runtime-core.js";
4
+
5
+ export const key = "img-in";
6
+ export const readyEvent = "gem:img-in:ready";
7
+
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
9
+ registerPlugin('img-in', ({ api }) => {
10
+ const STATE = 'data-gem-img-in-state';
11
+ const BOUND = 'data-gem-bound-img-in';
12
+ const reduce = typeof target.matchMedia === 'function' && target.matchMedia('(prefers-reduced-motion: reduce)').matches;
13
+ const style = (img) => (typeof target.getComputedStyle === 'function' ? target.getComputedStyle(img) : null);
14
+ const owned = (s) => Boolean(s && ((s.opacity && s.opacity !== '1') || (s.filter && s.filter !== 'none') || (s.animationName && s.animationName !== 'none')));
15
+ const overridden = (s) => Boolean(s && ((s.opacity && s.opacity !== '0.6') || (s.filter && s.filter.indexOf('blur') === -1)));
16
+ const settle = (event) => {
17
+ const img = event.target;
18
+ if (!img || img.tagName !== 'IMG' || !img.hasAttribute(BOUND)) return;
19
+ const state = img.getAttribute(STATE);
20
+ if (event.type === 'load' || event.type === 'error') {
21
+ if (state !== 'loading') return;
22
+ if (event.type === 'load' && !reduce && !overridden(style(img))) img.setAttribute(STATE, 'in');
23
+ else img.removeAttribute(STATE);
24
+ return;
25
+ }
26
+ if (state === 'in' && event.animationName === 'gem-img-in') img.removeAttribute(STATE);
27
+ };
28
+ ['load', 'error', 'animationend', 'animationcancel'].forEach((type) => __gemListen(document, type, settle, true));
29
+ const candidates = [];
30
+ document.querySelectorAll('img').forEach((img) => {
31
+ if (!img.closest('[data-gem-img-in]')) return;
32
+ if (img.complete) { if (!img.hasAttribute(BOUND)) img.removeAttribute(STATE); return; }
33
+ if (mark(img, 'img-in')) candidates.push(img);
34
+ });
35
+ const own = candidates.map((img) => owned(style(img)));
36
+ let bound = 0;
37
+ candidates.forEach((img, i) => { if (own[i]) return; img.setAttribute(STATE, 'loading'); bound += 1; });
38
+ api.emit('gem:img-in:ready', { source: 'gem-runtime', count: bound });
39
+ });
40
+ }
41
+
42
+ /** Bind every "img-in" hook inside root (default: the whole document). Returns unmount(). Mounting the same root twice binds once. */
43
+ export function mount(root, options) {
44
+ return mountPlugin(key, install, root, options);
45
+ }
46
+
47
+ export default mount;
@@ -143,6 +143,16 @@ a {
143
143
  border-color: transparent;
144
144
  color: var(--gem-color-text);
145
145
  }
146
+ @supports (color: rgb(from color-mix(in srgb, red 50%, rgb(from blue calc(255 - r) g b)) clamp(0, (r - 127.5) * 1000, 255) g b)) {
147
+ [data-gem-button-style='gradient'] .gem-button:not(.gem-button--secondary, .gem-button--link),
148
+ [data-gem-button-style='gradient'] .gem-button--primary {
149
+ background-color: var(--gem-color-accent);
150
+ background-image: linear-gradient(135deg, var(--gem-color-accent), color-mix(in srgb, var(--gem-color-accent) 75%, rgb(from color-mix(in srgb, var(--gem-color-accent) 50%, rgb(from var(--gem-color-accent-text, var(--gem-color-surface)) calc(255 - r) calc(255 - g) calc(255 - b))) clamp(0, (0.2126 * r + 0.7152 * g + 0.0722 * b - 127.5) * 1000, 255) clamp(0, (0.2126 * r + 0.7152 * g + 0.0722 * b - 127.5) * 1000, 255) clamp(0, (0.2126 * r + 0.7152 * g + 0.0722 * b - 127.5) * 1000, 255) / 1)));
151
+ background-origin: border-box;
152
+ border-color: transparent;
153
+ color: var(--gem-color-accent-text, var(--gem-color-surface));
154
+ }
155
+ }
146
156
  .gem-card {
147
157
  border: var(--gem-border-subtle, 1px solid var(--gem-color-border));
148
158
  border-radius: var(--gem-card-radius, var(--gem-radius-md));
@@ -1019,6 +1029,9 @@ a {
1019
1029
  [data-gem-reveal='zoom'][data-gem-reveal-state='ready'] { opacity: 0; transform: scale(0.94); }
1020
1030
  [data-gem-reveal='blur'][data-gem-reveal-state='ready'] { opacity: 0; filter: blur(10px); transform: none; }
1021
1031
  @media (prefers-reduced-motion: reduce) { [data-gem-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; } }
1032
+ :where(img[data-gem-bound-img-in][data-gem-img-in-state='loading']) { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; }
1033
+ @media (prefers-reduced-motion: no-preference) { :where(img[data-gem-bound-img-in][data-gem-img-in-state='in']) { animation: gem-img-in var(--gem-motion-slow, 260ms) var(--gem-ease-enter, cubic-bezier(.33, 1, .68, 1)); } }
1034
+ @keyframes gem-img-in { from { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; } }
1022
1035
  @keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }
1023
1036
  @keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
1024
1037
  @keyframes gem-marquee-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@@ -139,6 +139,13 @@ function aaLabelColor(preferred, fill, fallbacks = []) {
139
139
  if (pass) return pass;
140
140
  return candidates.reduce((a, b) => hexContrastRatio(b, ground) > hexContrastRatio(a, ground) ? b : a, preferred);
141
141
  }
142
+ var GEM_BUTTON_GRADIENT_SHIFT = 0.25;
143
+ var GEM_BUTTON_GRADIENT_PIVOT = 127.5;
144
+ function gemButtonGradientEndCss(accent = "var(--gem-color-accent)", accentText = "var(--gem-color-accent-text, var(--gem-color-surface))") {
145
+ const t = `clamp(0, (0.2126 * r + 0.7152 * g + 0.0722 * b - ${GEM_BUTTON_GRADIENT_PIVOT}) * 1000, 255)`;
146
+ const probe = `color-mix(in srgb, ${accent} 50%, rgb(from ${accentText} calc(255 - r) calc(255 - g) calc(255 - b)))`;
147
+ return `color-mix(in srgb, ${accent} ${100 - GEM_BUTTON_GRADIENT_SHIFT * 100}%, rgb(from ${probe} ${t} ${t} ${t} / 1))`;
148
+ }
142
149
 
143
150
  // ../../packages/gem-framework/src/tokens.ts
144
151
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
@@ -361,11 +368,41 @@ var isLineHeightKey = (k) => GEM_LINE_HEIGHT_TOKEN_KEYS.some((l) => tokenKeyNorm
361
368
  var badLineHeight = (k, v) => isLineHeightKey(k) && !LINE_HEIGHT_SHAPE.test(stripImportant(String(v ?? "")).trim());
362
369
  var desktopEntries = (group, values) => values ? Object.fromEntries(Object.entries(values).filter(([key]) => gemPhoneTokenTarget(group, key) == null)) : values;
363
370
  var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
371
+ var GEM_MOTION_SCALE_DEFAULTS = {
372
+ fast: "100ms",
373
+ base: "180ms",
374
+ slow: "260ms",
375
+ enter: "cubic-bezier(.33, 1, .68, 1)",
376
+ exit: "cubic-bezier(.4, 0, 1, 1)",
377
+ distance: "8px",
378
+ distanceLg: "16px",
379
+ stagger: "30ms",
380
+ blur: "8px"
381
+ };
382
+ var GEM_MOTION_REDUCED = {
383
+ fast: "1ms",
384
+ base: "1ms",
385
+ slow: "1ms",
386
+ distance: "0px",
387
+ distanceLg: "0px",
388
+ stagger: "0ms",
389
+ blur: "0px"
390
+ };
391
+ var cssVarName = (line) => line.slice(0, line.indexOf(":")).trim();
392
+ function gemMotionScaleCssVars(motion) {
393
+ const declared = new Set(toMotionCssVars(motion ?? {}).map(cssVarName));
394
+ const duration = Object.entries(motion ?? {}).find(([key]) => motionCssVarName(key) === "--gem-motion-duration")?.[1];
395
+ const still = /^0+(?:\.0+)?(?:m?s)?$/i.test(String(duration ?? "").trim());
396
+ const scale = still ? { ...GEM_MOTION_SCALE_DEFAULTS, ...GEM_MOTION_REDUCED } : GEM_MOTION_SCALE_DEFAULTS;
397
+ return toMotionCssVars(scale).filter((line) => !declared.has(cssVarName(line)));
398
+ }
364
399
  var optionalGemTokenKeys = {
365
400
  typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
366
401
  spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
367
402
  layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
368
- radius: [...GEM_BUTTON_TOKEN_KEYS.radius]
403
+ radius: [...GEM_BUTTON_TOKEN_KEYS.radius],
404
+ // Khoá motion ngoài duration / easing: nền ambient (đã phát từ trước nhưng chưa khai ở đây) và thang "Gọn" (#32).
405
+ motion: ["easingSoft", "ambientSlow", "ambientMid", "ambientFast", "ambientAmp", ...Object.keys(GEM_MOTION_SCALE_DEFAULTS)]
369
406
  };
370
407
  var GEM_HEADING_SIZE_CSS_VARS = [
371
408
  "--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
@@ -479,6 +516,8 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
479
516
  // and marquee primitives consume; unrecognised keys fall back to the --gem-motion-* namespace,
480
517
  // so duration/easing keep emitting --gem-motion-duration/easing exactly as before (back-compat).
481
518
  ...toMotionCssVars(tokens.motion),
519
+ // Thang "Gọn" (#32): khoá plan không khai thì phát mặc định, sau các dòng của plan để thứ tự cũ không đổi.
520
+ ...gemMotionScaleCssVars(tokens.motion),
482
521
  ...toCssVars("layout", desktopEntries("layout", tokens.layout)),
483
522
  ...phone.layout.section ? [`--gem-layout-section-mobile: ${phone.layout.section};`] : [],
484
523
  ...toCssVars("component", tokens.component),
@@ -511,6 +550,14 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
511
550
  "}",
512
551
  ...bandBorders.length ? [`:where(${bandScope}) {`, ...bandBorders.map((line) => ` ${line}`), "}"] : [],
513
552
  ...mobile.length ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ...mobile.map((line) => ` ${line}`), " }", "}"] : [],
553
+ "@media (prefers-reduced-motion: reduce) {",
554
+ ` ${selector} {`,
555
+ // !important ở CUỐI khai báo (hợp lệ, khác cờ lọt vào giữa giá trị): stylesheet đến sau khai thang trên :root
556
+ // (gemsite theme-plan-style) không đè được lựa chọn giảm chuyển động của người dùng. Section tự khai thang trên chính
557
+ // nó vẫn giữ giá trị của nó, như mọi biến kế thừa.
558
+ ...toMotionCssVars(GEM_MOTION_REDUCED).map((line) => ` ${line.replace(/;$/, " !important;")}`),
559
+ " }",
560
+ "}",
514
561
  ...formBlock ? [formBlock] : [],
515
562
  ...buttonCase ? [buttonCase] : []
516
563
  ].join("\n");
@@ -559,16 +606,22 @@ function toCssVars(namespace, values) {
559
606
  }
560
607
  var motionCssVarAliases = {
561
608
  easingSoft: "--gem-ease-soft",
609
+ enter: "--gem-ease-enter",
610
+ exit: "--gem-ease-exit",
562
611
  ambientSlow: "--gem-ambient-slow",
563
612
  ambientMid: "--gem-ambient-mid",
564
613
  ambientFast: "--gem-ambient-fast",
565
614
  ambientAmp: "--gem-ambient-amp"
566
615
  };
616
+ var motionCssVarAliasBySlug = Object.fromEntries(
617
+ Object.entries(motionCssVarAliases).map(([key, name]) => [slugify(key), name])
618
+ );
619
+ function motionCssVarName(key) {
620
+ return motionCssVarAliasBySlug[slugify(key)] ?? `--gem-motion-${slugify(key)}`;
621
+ }
567
622
  function toMotionCssVars(values) {
568
- return Object.entries(values).map(([key, value]) => {
569
- const name = motionCssVarAliases[key] ?? `--gem-motion-${slugify(key)}`;
570
- return `${name}: ${value};`;
571
- });
623
+ if (!values) return [];
624
+ return Object.entries(values).map(([key, value]) => `${motionCssVarName(key)}: ${value};`);
572
625
  }
573
626
  function toScaledCssVars(namespace, values, knob) {
574
627
  if (!values) return [];
@@ -1185,6 +1238,12 @@ var GEM_REVEAL_CSS_LINES = [
1185
1238
  "@media (prefers-reduced-motion: reduce) { [data-gem-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; } }"
1186
1239
  ];
1187
1240
  var GEM_REVEAL_CSS = GEM_REVEAL_CSS_LINES.join("\n");
1241
+ var GEM_IMG_IN_CSS_LINES = [
1242
+ ":where(img[data-gem-bound-img-in][data-gem-img-in-state='loading']) { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; }",
1243
+ "@media (prefers-reduced-motion: no-preference) { :where(img[data-gem-bound-img-in][data-gem-img-in-state='in']) { animation: gem-img-in var(--gem-motion-slow, 260ms) var(--gem-ease-enter, cubic-bezier(.33, 1, .68, 1)); } }",
1244
+ "@keyframes gem-img-in { from { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; } }"
1245
+ ];
1246
+ var GEM_IMG_IN_CSS = GEM_IMG_IN_CSS_LINES.join("\n");
1188
1247
  var GEM_AMBIENT_CSS_LINES = [
1189
1248
  "@keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }",
1190
1249
  "@keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }",
@@ -1438,6 +1497,9 @@ function createPrimitiveCss() {
1438
1497
  // and `.gem-button--soft` following button_style on the published storefront but NOT in the
1439
1498
  // preview, which is the drift this rule exists to remove. `--primary` is named too so the
1440
1499
  // modifier keeps winning where both could apply.
1500
+ // KNOWN (11/10/2026): these two also reach `.gem-button--link` (soft paints its tint behind a link-styled button,
1501
+ // outline recolours it). The gradient rule below leaves the link out; fixing these changes stores that did not ask,
1502
+ // so it waits for the next framework edition (editions.ts).
1441
1503
  "[data-gem-button-style='outline'] .gem-button:not(.gem-button--secondary),",
1442
1504
  "[data-gem-button-style='outline'] .gem-button--primary {",
1443
1505
  " background: transparent;",
@@ -1449,6 +1511,28 @@ function createPrimitiveCss() {
1449
1511
  " border-color: transparent;",
1450
1512
  " color: var(--gem-color-text);",
1451
1513
  "}",
1514
+ // `gradient` (0.13.0). GemFactory Global Style, 339 top-store pages: the main CTA is a gradient on 8.4% (10.6% of
1515
+ // landing pages), and 16 of the 21 measured are ONE hue going lighter → darker; 14 of the 21 drew their text under
1516
+ // 4.5:1. The far stop is the accent moved GEM_BUTTON_GRADIENT_SHIFT AWAY from the button text: toward black when the
1517
+ // accent is the darker of the two, toward white when it is the lighter (gemButtonGradientEnd is the same sum in JS;
1518
+ // contrast.ts has the two versions that failed), so the text only gains contrast across the button. It reads
1519
+ // --gem-color-accent / -accent-text, which a dark or accent band points at its own scheme, so a gradient button inside
1520
+ // a band follows the band. The text colour is set here because a bare `.gem-button` sets none. The link variant is
1521
+ // left out (it is text). Behind @supports, and in LONGHANDS: @supports only proves the syntax parses, and a value that
1522
+ // still fails at computed time (a var() that does not resolve) would take the whole `background` shorthand to
1523
+ // transparent under white text (review of #737, reproduced). As longhands only the image drops, over the accent.
1524
+ // Near black or white the two stops all but meet (#121212 → #0e0e0e): moving toward the text would break the AA
1525
+ // promise, so such a palette draws a flat button, and GemFactory's preset gate says so.
1526
+ "@supports (color: rgb(from color-mix(in srgb, red 50%, rgb(from blue calc(255 - r) g b)) clamp(0, (r - 127.5) * 1000, 255) g b)) {",
1527
+ " [data-gem-button-style='gradient'] .gem-button:not(.gem-button--secondary, .gem-button--link),",
1528
+ " [data-gem-button-style='gradient'] .gem-button--primary {",
1529
+ " background-color: var(--gem-color-accent);",
1530
+ ` background-image: linear-gradient(135deg, var(--gem-color-accent), ${gemButtonGradientEndCss()});`,
1531
+ " background-origin: border-box;",
1532
+ " border-color: transparent;",
1533
+ " color: var(--gem-color-accent-text, var(--gem-color-surface));",
1534
+ " }",
1535
+ "}",
1452
1536
  ".gem-card {",
1453
1537
  " border: var(--gem-border-subtle, 1px solid var(--gem-color-border));",
1454
1538
  " border-radius: var(--gem-card-radius, var(--gem-radius-md));",
@@ -2324,6 +2408,7 @@ function createPrimitiveCss() {
2324
2408
  " opacity: 1;",
2325
2409
  "}",
2326
2410
  ...GEM_REVEAL_CSS_LINES,
2411
+ ...GEM_IMG_IN_CSS_LINES,
2327
2412
  ...GEM_AMBIENT_CSS_LINES,
2328
2413
  ...GEM_DIVIDER_CSS_LINES,
2329
2414
  ...GEM_CARD_CSS_LINES,
@@ -4371,6 +4456,8 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
4371
4456
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
4372
4457
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
4373
4458
  GEM_FOCUS_OFFSET_CSS_VAR.split(":")[0].trim(),
4459
+ // Thang chuyển động "Gọn" (tokens.ts GEM_MOTION_SCALE_DEFAULTS, #32): luôn phát, section đọc được.
4460
+ ...toMotionCssVars(GEM_MOTION_SCALE_DEFAULTS).map((line) => line.split(":")[0].trim()),
4374
4461
  // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
4375
4462
  ...GEM_DERIVED_SPACE_CSS_VARIABLES,
4376
4463
  // Extension points a section SETS (gemtheme #650): icon size, drawer slide distance.
package/dist/tokens.css CHANGED
@@ -37,6 +37,15 @@
37
37
  --gem-ambient-mid: 7s;
38
38
  --gem-ambient-fast: 5.5s;
39
39
  --gem-ambient-amp: 7px;
40
+ --gem-motion-fast: 100ms;
41
+ --gem-motion-base: 180ms;
42
+ --gem-motion-slow: 260ms;
43
+ --gem-ease-enter: cubic-bezier(.33, 1, .68, 1);
44
+ --gem-ease-exit: cubic-bezier(.4, 0, 1, 1);
45
+ --gem-motion-distance: 8px;
46
+ --gem-motion-distance-lg: 16px;
47
+ --gem-motion-stagger: 30ms;
48
+ --gem-motion-blur: 8px;
40
49
  --gem-layout-page: 1180px;
41
50
  --gem-layout-gutter: 32px;
42
51
  --gem-layout-section: 64px;
@@ -68,4 +77,15 @@
68
77
  --gem-border-subtle: calc(1px * var(--gem-style-border, 1)) solid var(--gem-color-border);
69
78
  --gem-border-strong: calc(1px * var(--gem-style-border, 1)) solid color-mix(in srgb, var(--gem-color-text) 28%, transparent);
70
79
  }
80
+ @media (prefers-reduced-motion: reduce) {
81
+ :root {
82
+ --gem-motion-fast: 1ms !important;
83
+ --gem-motion-base: 1ms !important;
84
+ --gem-motion-slow: 1ms !important;
85
+ --gem-motion-distance: 0px !important;
86
+ --gem-motion-distance-lg: 0px !important;
87
+ --gem-motion-stagger: 0ms !important;
88
+ --gem-motion-blur: 0px !important;
89
+ }
90
+ }
71
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/gem-framework",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "gem-framework của GemTheme (CSS, runtime, token, bộ kiểm) cộng đúng bộ kiểm section gemtheme dùng khi duyệt. Build từ gemtheme, không sửa ở đây.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",