@gemboss/gem-framework 0.13.0 → 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,33 @@ 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.
96
123
  - **0.13.0** (11/10/2026): kiểu nút **`gradient`** (`plan.global_settings.button_style`, `<body data-gem-button-style="gradient">`).
97
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
98
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%
package/dist/SOURCE.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "@gemboss/gem-framework",
3
- "version": "0.13.0",
4
- "gemtheme_sha": "3e6e73a7466bf54609b5e81abfc3c4d4a06b42d6",
3
+ "version": "0.14.0",
4
+ "gemtheme_sha": "75954020da59dcca83cdae5676c0a9746e40ba4b",
5
5
  "gemtheme_dirty": false,
6
- "framework_version": "0.13.0"
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;
@@ -1100,6 +1120,9 @@ a {
1100
1120
  [data-gem-reveal='zoom'][data-gem-reveal-state='ready'] { opacity: 0; transform: scale(0.94); }
1101
1121
  [data-gem-reveal='blur'][data-gem-reveal-state='ready'] { opacity: 0; filter: blur(10px); transform: none; }
1102
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; } }
1103
1126
  @keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }
1104
1127
  @keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
1105
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
@@ -280,7 +280,7 @@ function gemButtonGradientEndCss(accent = "var(--gem-color-accent)", accentText
280
280
  }
281
281
 
282
282
  // ../../packages/gem-framework/src/tokens.ts
283
- var gemFrameworkVersion = "0.13.0";
283
+ var gemFrameworkVersion = "0.14.0";
284
284
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
285
285
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
286
286
  var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
@@ -501,11 +501,41 @@ var isLineHeightKey = (k) => GEM_LINE_HEIGHT_TOKEN_KEYS.some((l) => tokenKeyNorm
501
501
  var badLineHeight = (k, v) => isLineHeightKey(k) && !LINE_HEIGHT_SHAPE.test(stripImportant(String(v ?? "")).trim());
502
502
  var desktopEntries = (group, values) => values ? Object.fromEntries(Object.entries(values).filter(([key]) => gemPhoneTokenTarget(group, key) == null)) : values;
503
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
+ }
504
532
  var optionalGemTokenKeys = {
505
533
  typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
506
534
  spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
507
535
  layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
508
- 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)]
509
539
  };
510
540
  var GEM_HEADING_SIZE_CSS_VARS = [
511
541
  "--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
@@ -662,6 +692,8 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
662
692
  // and marquee primitives consume; unrecognised keys fall back to the --gem-motion-* namespace,
663
693
  // so duration/easing keep emitting --gem-motion-duration/easing exactly as before (back-compat).
664
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),
665
697
  ...toCssVars("layout", desktopEntries("layout", tokens.layout)),
666
698
  ...phone.layout.section ? [`--gem-layout-section-mobile: ${phone.layout.section};`] : [],
667
699
  ...toCssVars("component", tokens.component),
@@ -694,6 +726,14 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
694
726
  "}",
695
727
  ...bandBorders.length ? [`:where(${bandScope}) {`, ...bandBorders.map((line) => ` ${line}`), "}"] : [],
696
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
+ "}",
697
737
  ...formBlock ? [formBlock] : [],
698
738
  ...buttonCase ? [buttonCase] : []
699
739
  ].join("\n");
@@ -742,16 +782,22 @@ function toCssVars(namespace, values) {
742
782
  }
743
783
  var motionCssVarAliases = {
744
784
  easingSoft: "--gem-ease-soft",
785
+ enter: "--gem-ease-enter",
786
+ exit: "--gem-ease-exit",
745
787
  ambientSlow: "--gem-ambient-slow",
746
788
  ambientMid: "--gem-ambient-mid",
747
789
  ambientFast: "--gem-ambient-fast",
748
790
  ambientAmp: "--gem-ambient-amp"
749
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
+ }
750
798
  function toMotionCssVars(values) {
751
- return Object.entries(values).map(([key, value]) => {
752
- const name = motionCssVarAliases[key] ?? `--gem-motion-${slugify(key)}`;
753
- return `${name}: ${value};`;
754
- });
799
+ if (!values) return [];
800
+ return Object.entries(values).map(([key, value]) => `${motionCssVarName(key)}: ${value};`);
755
801
  }
756
802
  function toScaledCssVars(namespace, values, knob) {
757
803
  if (!values) return [];
@@ -1200,6 +1246,11 @@ var gemFrameworkRuntimePlugins = [
1200
1246
  load_strategy: "viewport",
1201
1247
  events: ["gem:reveal:ready", "gem:reveal:in"]
1202
1248
  },
1249
+ {
1250
+ key: "img-in",
1251
+ load_strategy: "critical",
1252
+ events: ["gem:img-in:ready"]
1253
+ },
1203
1254
  {
1204
1255
  key: "seasonal-effects",
1205
1256
  load_strategy: "idle",
@@ -1526,6 +1577,12 @@ var GEM_REVEAL_CSS_LINES = [
1526
1577
  "@media (prefers-reduced-motion: reduce) { [data-gem-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; } }"
1527
1578
  ];
1528
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");
1529
1586
  var GEM_AMBIENT_CSS_LINES = [
1530
1587
  "@keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }",
1531
1588
  "@keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }",
@@ -2697,6 +2754,7 @@ function createPrimitiveCss() {
2697
2754
  " opacity: 1;",
2698
2755
  "}",
2699
2756
  ...GEM_REVEAL_CSS_LINES,
2757
+ ...GEM_IMG_IN_CSS_LINES,
2700
2758
  ...GEM_AMBIENT_CSS_LINES,
2701
2759
  ...GEM_DIVIDER_CSS_LINES,
2702
2760
  ...GEM_CARD_CSS_LINES,
@@ -4605,6 +4663,7 @@ var runtimePluginReadyEvents = {
4605
4663
  "recently-viewed": "gem:recently-viewed:ready",
4606
4664
  countdown: "gem:countdown:ready",
4607
4665
  "reveal-on-scroll": "gem:reveal:ready",
4666
+ "img-in": "gem:img-in:ready",
4608
4667
  "seasonal-effects": "gem:seasonal:ready",
4609
4668
  "context-adapter": "gem:context:ready",
4610
4669
  "offer-engine": "gem:offer:ready"
@@ -5574,6 +5633,39 @@ var revealOnScrollPluginRegistrationLines = [
5574
5633
  " api.emit('gem:reveal:ready', { source: 'gem-runtime', count: bound });",
5575
5634
  " });"
5576
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
+ ];
5577
5669
  var recentlyViewedPluginRegistrationLines = [
5578
5670
  " registerPlugin('recently-viewed', ({ api }) => {",
5579
5671
  " const storageKey = 'gem:recently-viewed-products';",
@@ -5670,6 +5762,7 @@ var runtimePluginSourceLines = [
5670
5762
  ["before_after", beforeAfterPluginRegistrationLines],
5671
5763
  ["countdown", countdownPluginRegistrationLines],
5672
5764
  ["reveal-on-scroll", revealOnScrollPluginRegistrationLines],
5765
+ ["img-in", imgInPluginRegistrationLines],
5673
5766
  ["recently-viewed", recentlyViewedPluginRegistrationLines],
5674
5767
  ["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
5675
5768
  ["offer-engine", offerEnginePluginRegistrationLines]
@@ -5971,6 +6064,8 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
5971
6064
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
5972
6065
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
5973
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()),
5974
6069
  // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
5975
6070
  ...GEM_DERIVED_SPACE_CSS_VARIABLES,
5976
6071
  // Extension points a section SETS (gemtheme #650): icon size, drawer slide distance.
@@ -7316,7 +7411,10 @@ export {
7316
7411
  GEM_FRAMEWORK_EDITIONS,
7317
7412
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
7318
7413
  GEM_HEADING_SIZE_CSS_VARS,
7414
+ GEM_IMG_IN_CSS,
7319
7415
  GEM_LINE_HEIGHT_TOKEN_KEYS,
7416
+ GEM_MOTION_REDUCED,
7417
+ GEM_MOTION_SCALE_DEFAULTS,
7320
7418
  GEM_PART_CLASS,
7321
7419
  GEM_POPUP_SHELL_CSS,
7322
7420
  GEM_REVEAL_CSS,
@@ -7371,6 +7469,7 @@ export {
7371
7469
  gemFrameworkValidationRules,
7372
7470
  gemFrameworkVersion,
7373
7471
  gemMobileTokenCssVars,
7472
+ gemMotionScaleCssVars,
7374
7473
  gemPartClass,
7375
7474
  gemPhoneTokenTarget,
7376
7475
  gemPlanShapeKnobs,
@@ -7384,6 +7483,7 @@ export {
7384
7483
  modernMineralFamilyLineage,
7385
7484
  modernMineralHomepageLineageSections,
7386
7485
  motionCssVarAliases,
7486
+ motionCssVarName,
7387
7487
  normalizeGemTokens,
7388
7488
  normalizeHex,
7389
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;
@@ -1029,6 +1029,9 @@ a {
1029
1029
  [data-gem-reveal='zoom'][data-gem-reveal-state='ready'] { opacity: 0; transform: scale(0.94); }
1030
1030
  [data-gem-reveal='blur'][data-gem-reveal-state='ready'] { opacity: 0; filter: blur(10px); transform: none; }
1031
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; } }
1032
1035
  @keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }
1033
1036
  @keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
1034
1037
  @keyframes gem-marquee-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@@ -368,11 +368,41 @@ var isLineHeightKey = (k) => GEM_LINE_HEIGHT_TOKEN_KEYS.some((l) => tokenKeyNorm
368
368
  var badLineHeight = (k, v) => isLineHeightKey(k) && !LINE_HEIGHT_SHAPE.test(stripImportant(String(v ?? "")).trim());
369
369
  var desktopEntries = (group, values) => values ? Object.fromEntries(Object.entries(values).filter(([key]) => gemPhoneTokenTarget(group, key) == null)) : values;
370
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
+ }
371
399
  var optionalGemTokenKeys = {
372
400
  typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
373
401
  spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
374
402
  layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
375
- 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)]
376
406
  };
377
407
  var GEM_HEADING_SIZE_CSS_VARS = [
378
408
  "--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
@@ -486,6 +516,8 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
486
516
  // and marquee primitives consume; unrecognised keys fall back to the --gem-motion-* namespace,
487
517
  // so duration/easing keep emitting --gem-motion-duration/easing exactly as before (back-compat).
488
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),
489
521
  ...toCssVars("layout", desktopEntries("layout", tokens.layout)),
490
522
  ...phone.layout.section ? [`--gem-layout-section-mobile: ${phone.layout.section};`] : [],
491
523
  ...toCssVars("component", tokens.component),
@@ -518,6 +550,14 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
518
550
  "}",
519
551
  ...bandBorders.length ? [`:where(${bandScope}) {`, ...bandBorders.map((line) => ` ${line}`), "}"] : [],
520
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
+ "}",
521
561
  ...formBlock ? [formBlock] : [],
522
562
  ...buttonCase ? [buttonCase] : []
523
563
  ].join("\n");
@@ -566,16 +606,22 @@ function toCssVars(namespace, values) {
566
606
  }
567
607
  var motionCssVarAliases = {
568
608
  easingSoft: "--gem-ease-soft",
609
+ enter: "--gem-ease-enter",
610
+ exit: "--gem-ease-exit",
569
611
  ambientSlow: "--gem-ambient-slow",
570
612
  ambientMid: "--gem-ambient-mid",
571
613
  ambientFast: "--gem-ambient-fast",
572
614
  ambientAmp: "--gem-ambient-amp"
573
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
+ }
574
622
  function toMotionCssVars(values) {
575
- return Object.entries(values).map(([key, value]) => {
576
- const name = motionCssVarAliases[key] ?? `--gem-motion-${slugify(key)}`;
577
- return `${name}: ${value};`;
578
- });
623
+ if (!values) return [];
624
+ return Object.entries(values).map(([key, value]) => `${motionCssVarName(key)}: ${value};`);
579
625
  }
580
626
  function toScaledCssVars(namespace, values, knob) {
581
627
  if (!values) return [];
@@ -1192,6 +1238,12 @@ var GEM_REVEAL_CSS_LINES = [
1192
1238
  "@media (prefers-reduced-motion: reduce) { [data-gem-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; } }"
1193
1239
  ];
1194
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");
1195
1247
  var GEM_AMBIENT_CSS_LINES = [
1196
1248
  "@keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }",
1197
1249
  "@keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }",
@@ -2356,6 +2408,7 @@ function createPrimitiveCss() {
2356
2408
  " opacity: 1;",
2357
2409
  "}",
2358
2410
  ...GEM_REVEAL_CSS_LINES,
2411
+ ...GEM_IMG_IN_CSS_LINES,
2359
2412
  ...GEM_AMBIENT_CSS_LINES,
2360
2413
  ...GEM_DIVIDER_CSS_LINES,
2361
2414
  ...GEM_CARD_CSS_LINES,
@@ -4403,6 +4456,8 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
4403
4456
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
4404
4457
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
4405
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()),
4406
4461
  // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
4407
4462
  ...GEM_DERIVED_SPACE_CSS_VARIABLES,
4408
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.13.0",
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",