@gemboss/gem-framework 0.13.0 → 0.15.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
@@ -6,10 +6,10 @@ gem-framework của GemTheme đóng thành package, cho các repo **ngoài** mon
6
6
  | Cổng vào | Là gì | Nguồn |
7
7
  |---|---|---|
8
8
  | `@gemboss/gem-framework` | token, `createGemThemeCss`, runtime, manifest, bộ kiểm framework. Từ 0.3.4 manifest khai họ `gem-cart-line-item` (khối, `__media`, `__details`, `__title`, `__variant`, `__plan`, `__meta`, `__remove`, `__quantity`, `__price`): framework vốn style và adapter Shopify vốn render nó, nên section dùng markup dòng giỏ của framework không còn bị `unknown_gem_class` | `packages/gem-framework/src` |
9
- | `…/sections` | 4 hàm `headless.tsx` được import (`elementOverrides`, `imageSrcSet`, `subscriptionPlans`, `defaultPlan`) | `packages/gem-sections/src` |
9
+ | `…/sections` | Các hàm `headless.tsx` được import: `elementOverrides`, `imageSrcSet`, `subscriptionPlans`, `defaultPlan`; từ 0.15.0 thêm bộ đọc nhấn trong câu `inlineMarkRuns`, `phraseMarkRuns`, `markClassName`, `inlineMarkText`, `safeMarkHref`, `MARK_KINDS` | `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,56 @@ 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.15.0** (11/10/2026): CHỈ THÊM, store đang chạy không đổi pixel nào (chưa trang nào mang các lớp mới). Gap #39: **nhấn
97
+ trong câu**. Chữ advertorial sống nhờ cách nhấn (kho GemFactory `research/advertorial-blocks-2026-10-10`: souleora 84 cụm đậm,
98
+ 5 nghiêng, 4 đổi màu, 1 gạch chân trong 152 đoạn; 28pilates chốt thẻ "đừng mua" bằng câu đỏ, tô màu nhấn cụm kết quả trong
99
+ lời khách; 29% store advertorial dẫn đi mua bằng link trong đoạn), mà element `text` / `heading` chỉ đậm được, và ba section
100
+ của kho mỗi cái chép một bộ đọc dấu riêng.
101
+ - `…/sections` (và `@gemtheme/gem-sections`): `inlineMarkRuns(chuỗi)` đọc `**đậm**`, `*nghiêng*`, `***đậm nghiêng***`,
102
+ `__gạch chân__`, `==tô sáng==`, `^^đậm màu nhấn^^`, `!!màu cảnh báo!!`, `[link](url)`, lồng được hai chiều (đậm trong
103
+ nghiêng, nghiêng trong đậm). Dấu chỉ mở / đóng sát chữ (không sát khoảng trắng), nên dấu câu thường giữ nguyên ("Hurry!!
104
+ Only 3 left!!", "^^", "a == b"); `__` không mở giữa từ (URL `a__b__c`); `\` giữ ký tự dấu nguyên văn; đọc từng dòng (dấu không
105
+ vắt qua xuống dòng), một dòng dài hơn 20.000 ký tự vẽ trơn; tuyến tính trên chuỗi xấu (dấu `[` lặp, đậm không đóng). `phraseMarkRuns(chữ,
106
+ { bold, italic, underline, highlight, accent, alert, links })` là cách của element: chữ vẫn trơn, mỗi dấu là danh sách
107
+ cụm từ (mỗi dòng một cụm, đặt ở chỗ xuất hiện ĐẦU TIÊN không chồng cụm cùng dấu, đúng luật `bold` đang dùng nên
108
+ `{ bold }` vẽ y như trước), link là dòng `cụm | url`. Cả hai trả về DỮ LIỆU (đoạn chữ + dấu + href), người gọi tự vẽ
109
+ `strong` / `em` / `span.gem-mark--…` / `a.gem-mark-link`: gói không kéo React, không chèn HTML; link chỉ giữ http(s),
110
+ đường dẫn trong site và #neo. `markClassName(marks)`, `inlineMarkText(chuỗi)` (chữ bỏ dấu), `safeMarkHref`.
111
+ - CSS (`GEM_INLINE_MARK_CSS`, trong `createPrimitiveCss`): `.gem-mark--underline`, `--highlight` (bút dạ: nền
112
+ `--gem-mark-highlight` #fff1a6, mực `--gem-mark-highlight-ink` #1f1d18, đọc được cả trên băng tối), `--accent`, `--alert`
113
+ (`--gem-color-danger`) trong `:where()` (độ ưu tiên 0: section muốn dáng riêng tự đè); `.gem-mark-link` (màu nhấn, gạch
114
+ chân) là một lớp thường, vì `a { color: inherit }` của framework và gemsite thắng mọi thứ trong `:where()`; link nằm
115
+ trong / chứa tô sáng giữ mực của bút dạ. Sáu lớp khai
116
+ trong manifest (nhóm content), nên section vẽ chúng không bị `unknown_gem_class`.
117
+ - Người dùng: gemboss phải thêm các tên mới vào requireShim của bản xem trước section; gemsite chép `inline-marks.ts`
118
+ byte-identical (cặp kiểm của `check-elements-copied-from-gemtheme`) rồi vẽ dấu cho element `text` / `heading`.
119
+ - **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
120
+ đọ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"
121
+ (Lily 08/10/2026) mà kho đang chép tay vào từng mục:
122
+ (1) **Thang chuyển động (#32).** `createGemTokenCss` luôn phát `--gem-motion-fast` 100ms, `-base` 180ms, `-slow` 260ms,
123
+ `--gem-ease-enter` / `--gem-ease-exit`, `--gem-motion-distance` 8px, `-distance-lg` 16px, `-stagger` 30ms, `-blur` 8px
124
+ (`GEM_MOTION_SCALE_DEFAULTS`, `gemMotionScaleCssVars`); plan đổi được qua `motion.fast|base|slow|enter|exit|distance|
125
+ 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.
126
+ Plan có `motion.duration` bằng 0 (style pack `motion_level: "none"`) nhận thang tĩnh. Kèm một khối
127
+ `@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
128
+ và độ nhoè về 0 (`GEM_MOTION_REDUCED`), mang `!important` ở cuối khai báo để stylesheet đến sau khai thang trên `:root`
129
+ 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
130
+ (`motionCssVarName(key)`, dùng hàm này thay vì tra `motionCssVarAliases` theo đúng chữ):
131
+ `easing_soft`, `ambient-slow`, `Enter` ra đúng `--gem-ease-soft`, `--gem-ambient-slow`, `--gem-ease-enter` (trước đó ra
132
+ `--gem-motion-easing-soft`…, biến không rule nào đọc).
133
+ (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
134
+ `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
135
+ 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ừ
136
+ 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,
137
+ 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
138
+ section đã tự đặt opacity / filter / animation (ảnh phụ chờ rê chuột, hero tối, `data-gem-ambient`). Một listener pha
139
+ 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
140
+ 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
141
+ 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`
142
+ 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ả
143
+ 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ó
144
+ 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ả
145
+ tabs, hero-slideshow, reveal-on-scroll, offer-engine): store nhận plugin qua `gem-runtime.js` hoặc module.
96
146
  - **0.13.0** (11/10/2026): kiểu nút **`gradient`** (`plan.global_settings.button_style`, `<body data-gem-button-style="gradient">`).
97
147
  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
148
  đượ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.15.0",
4
+ "gemtheme_sha": "c69c7c6e8d2c2dcf1543170990f6a644805c4dd8",
5
5
  "gemtheme_dirty": false,
6
- "framework_version": "0.13.0"
6
+ "framework_version": "0.15.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,17 @@ 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; } }
1126
+ :where(.gem-mark--underline) { text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }
1127
+ :where(.gem-mark--highlight) { background: var(--gem-mark-highlight, #fff1a6); color: var(--gem-mark-highlight-ink, #1f1d18); padding-inline: 0.12em; border-radius: 0.15em; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
1128
+ :where(.gem-mark--accent) { color: var(--gem-color-accent); }
1129
+ :where(.gem-mark--alert) { color: var(--gem-color-danger, #b42318); }
1130
+ :where(.gem-mark--highlight.gem-mark--accent), :where(.gem-mark--highlight.gem-mark--alert) { color: var(--gem-mark-highlight-ink, #1f1d18); }
1131
+ .gem-mark-link { color: var(--gem-color-accent); text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }
1132
+ .gem-mark-link:hover { text-decoration-thickness: 2px; }
1133
+ .gem-mark-link.gem-mark--highlight, .gem-mark--highlight .gem-mark-link { color: var(--gem-mark-highlight-ink, #1f1d18); }
1103
1134
  @keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }
1104
1135
  @keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
1105
1136
  @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.15.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 [];
@@ -895,7 +941,15 @@ var gemFrameworkPrimitiveGroups = [
895
941
  "gem-pull-quote",
896
942
  "gem-byline",
897
943
  "gem-disclaimer",
898
- "gem-subhead"
944
+ "gem-subhead",
945
+ // Nhấn trong câu (gap #39): lớp @gemtheme/gem-sections inline-marks.ts bảo người gọi vẽ, GEM_INLINE_MARK_CSS tô.
946
+ // Không khai thì section vẽ đúng dấu của framework vẫn bị gem_framework_unknown_gem_class (review #740).
947
+ "gem-mark",
948
+ "gem-mark--underline",
949
+ "gem-mark--highlight",
950
+ "gem-mark--accent",
951
+ "gem-mark--alert",
952
+ "gem-mark-link"
899
953
  ]
900
954
  },
901
955
  {
@@ -1200,6 +1254,11 @@ var gemFrameworkRuntimePlugins = [
1200
1254
  load_strategy: "viewport",
1201
1255
  events: ["gem:reveal:ready", "gem:reveal:in"]
1202
1256
  },
1257
+ {
1258
+ key: "img-in",
1259
+ load_strategy: "critical",
1260
+ events: ["gem:img-in:ready"]
1261
+ },
1203
1262
  {
1204
1263
  key: "seasonal-effects",
1205
1264
  load_strategy: "idle",
@@ -1526,6 +1585,25 @@ var GEM_REVEAL_CSS_LINES = [
1526
1585
  "@media (prefers-reduced-motion: reduce) { [data-gem-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; } }"
1527
1586
  ];
1528
1587
  var GEM_REVEAL_CSS = GEM_REVEAL_CSS_LINES.join("\n");
1588
+ var GEM_IMG_IN_CSS_LINES = [
1589
+ ":where(img[data-gem-bound-img-in][data-gem-img-in-state='loading']) { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; }",
1590
+ "@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)); } }",
1591
+ "@keyframes gem-img-in { from { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; } }"
1592
+ ];
1593
+ var GEM_IMG_IN_CSS = GEM_IMG_IN_CSS_LINES.join("\n");
1594
+ var GEM_INLINE_MARK_CSS_LINES = [
1595
+ ":where(.gem-mark--underline) { text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }",
1596
+ ":where(.gem-mark--highlight) { background: var(--gem-mark-highlight, #fff1a6); color: var(--gem-mark-highlight-ink, #1f1d18); padding-inline: 0.12em; border-radius: 0.15em; -webkit-box-decoration-break: clone; box-decoration-break: clone; }",
1597
+ ":where(.gem-mark--accent) { color: var(--gem-color-accent); }",
1598
+ ":where(.gem-mark--alert) { color: var(--gem-color-danger, #b42318); }",
1599
+ ":where(.gem-mark--highlight.gem-mark--accent), :where(.gem-mark--highlight.gem-mark--alert) { color: var(--gem-mark-highlight-ink, #1f1d18); }",
1600
+ ".gem-mark-link { color: var(--gem-color-accent); text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }",
1601
+ ".gem-mark-link:hover { text-decoration-thickness: 2px; }",
1602
+ // Bút dạ thắng màu link: chữ trên nền vàng giữ mực đậm, dù span nằm trong link hay link nằm trong span (review #740 đo màu nhấn
1603
+ // trên vàng 4,32:1 ở nền trắng, 2,47:1 trên băng tối, 1,05:1 trên băng màu nhấn).
1604
+ ".gem-mark-link.gem-mark--highlight, .gem-mark--highlight .gem-mark-link { color: var(--gem-mark-highlight-ink, #1f1d18); }"
1605
+ ];
1606
+ var GEM_INLINE_MARK_CSS = GEM_INLINE_MARK_CSS_LINES.join("\n");
1529
1607
  var GEM_AMBIENT_CSS_LINES = [
1530
1608
  "@keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }",
1531
1609
  "@keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }",
@@ -2697,6 +2775,8 @@ function createPrimitiveCss() {
2697
2775
  " opacity: 1;",
2698
2776
  "}",
2699
2777
  ...GEM_REVEAL_CSS_LINES,
2778
+ ...GEM_IMG_IN_CSS_LINES,
2779
+ ...GEM_INLINE_MARK_CSS_LINES,
2700
2780
  ...GEM_AMBIENT_CSS_LINES,
2701
2781
  ...GEM_DIVIDER_CSS_LINES,
2702
2782
  ...GEM_CARD_CSS_LINES,
@@ -4605,6 +4685,7 @@ var runtimePluginReadyEvents = {
4605
4685
  "recently-viewed": "gem:recently-viewed:ready",
4606
4686
  countdown: "gem:countdown:ready",
4607
4687
  "reveal-on-scroll": "gem:reveal:ready",
4688
+ "img-in": "gem:img-in:ready",
4608
4689
  "seasonal-effects": "gem:seasonal:ready",
4609
4690
  "context-adapter": "gem:context:ready",
4610
4691
  "offer-engine": "gem:offer:ready"
@@ -5574,6 +5655,39 @@ var revealOnScrollPluginRegistrationLines = [
5574
5655
  " api.emit('gem:reveal:ready', { source: 'gem-runtime', count: bound });",
5575
5656
  " });"
5576
5657
  ];
5658
+ var imgInPluginRegistrationLines = [
5659
+ " registerPlugin('img-in', ({ api }) => {",
5660
+ " const STATE = 'data-gem-img-in-state';",
5661
+ " const BOUND = 'data-gem-bound-img-in';",
5662
+ " const reduce = typeof target.matchMedia === 'function' && target.matchMedia('(prefers-reduced-motion: reduce)').matches;",
5663
+ " const style = (img) => (typeof target.getComputedStyle === 'function' ? target.getComputedStyle(img) : null);",
5664
+ " const owned = (s) => Boolean(s && ((s.opacity && s.opacity !== '1') || (s.filter && s.filter !== 'none') || (s.animationName && s.animationName !== 'none')));",
5665
+ " const overridden = (s) => Boolean(s && ((s.opacity && s.opacity !== '0.6') || (s.filter && s.filter.indexOf('blur') === -1)));",
5666
+ " const settle = (event) => {",
5667
+ " const img = event.target;",
5668
+ " if (!img || img.tagName !== 'IMG' || !img.hasAttribute(BOUND)) return;",
5669
+ " const state = img.getAttribute(STATE);",
5670
+ " if (event.type === 'load' || event.type === 'error') {",
5671
+ " if (state !== 'loading') return;",
5672
+ " if (event.type === 'load' && !reduce && !overridden(style(img))) img.setAttribute(STATE, 'in');",
5673
+ " else img.removeAttribute(STATE);",
5674
+ " return;",
5675
+ " }",
5676
+ " if (state === 'in' && event.animationName === 'gem-img-in') img.removeAttribute(STATE);",
5677
+ " };",
5678
+ " ['load', 'error', 'animationend', 'animationcancel'].forEach((type) => document.addEventListener(type, settle, true));",
5679
+ " const candidates = [];",
5680
+ " document.querySelectorAll('img').forEach((img) => {",
5681
+ " if (!img.closest('[data-gem-img-in]')) return;",
5682
+ " if (img.complete) { if (!img.hasAttribute(BOUND)) img.removeAttribute(STATE); return; }",
5683
+ " if (mark(img, 'img-in')) candidates.push(img);",
5684
+ " });",
5685
+ " const own = candidates.map((img) => owned(style(img)));",
5686
+ " let bound = 0;",
5687
+ " candidates.forEach((img, i) => { if (own[i]) return; img.setAttribute(STATE, 'loading'); bound += 1; });",
5688
+ " api.emit('gem:img-in:ready', { source: 'gem-runtime', count: bound });",
5689
+ " });"
5690
+ ];
5577
5691
  var recentlyViewedPluginRegistrationLines = [
5578
5692
  " registerPlugin('recently-viewed', ({ api }) => {",
5579
5693
  " const storageKey = 'gem:recently-viewed-products';",
@@ -5670,6 +5784,7 @@ var runtimePluginSourceLines = [
5670
5784
  ["before_after", beforeAfterPluginRegistrationLines],
5671
5785
  ["countdown", countdownPluginRegistrationLines],
5672
5786
  ["reveal-on-scroll", revealOnScrollPluginRegistrationLines],
5787
+ ["img-in", imgInPluginRegistrationLines],
5673
5788
  ["recently-viewed", recentlyViewedPluginRegistrationLines],
5674
5789
  ["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
5675
5790
  ["offer-engine", offerEnginePluginRegistrationLines]
@@ -5971,6 +6086,8 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
5971
6086
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
5972
6087
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
5973
6088
  GEM_FOCUS_OFFSET_CSS_VAR.split(":")[0].trim(),
6089
+ // Thang chuyển động "Gọn" (tokens.ts GEM_MOTION_SCALE_DEFAULTS, #32): luôn phát, section đọc được.
6090
+ ...toMotionCssVars(GEM_MOTION_SCALE_DEFAULTS).map((line) => line.split(":")[0].trim()),
5974
6091
  // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
5975
6092
  ...GEM_DERIVED_SPACE_CSS_VARIABLES,
5976
6093
  // Extension points a section SETS (gemtheme #650): icon size, drawer slide distance.
@@ -7316,7 +7433,11 @@ export {
7316
7433
  GEM_FRAMEWORK_EDITIONS,
7317
7434
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
7318
7435
  GEM_HEADING_SIZE_CSS_VARS,
7436
+ GEM_IMG_IN_CSS,
7437
+ GEM_INLINE_MARK_CSS,
7319
7438
  GEM_LINE_HEIGHT_TOKEN_KEYS,
7439
+ GEM_MOTION_REDUCED,
7440
+ GEM_MOTION_SCALE_DEFAULTS,
7320
7441
  GEM_PART_CLASS,
7321
7442
  GEM_POPUP_SHELL_CSS,
7322
7443
  GEM_REVEAL_CSS,
@@ -7371,6 +7492,7 @@ export {
7371
7492
  gemFrameworkValidationRules,
7372
7493
  gemFrameworkVersion,
7373
7494
  gemMobileTokenCssVars,
7495
+ gemMotionScaleCssVars,
7374
7496
  gemPartClass,
7375
7497
  gemPhoneTokenTarget,
7376
7498
  gemPlanShapeKnobs,
@@ -7384,6 +7506,7 @@ export {
7384
7506
  modernMineralFamilyLineage,
7385
7507
  modernMineralHomepageLineageSections,
7386
7508
  motionCssVarAliases,
7509
+ motionCssVarName,
7387
7510
  normalizeGemTokens,
7388
7511
  normalizeHex,
7389
7512
  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,17 @@ 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; } }
1035
+ :where(.gem-mark--underline) { text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }
1036
+ :where(.gem-mark--highlight) { background: var(--gem-mark-highlight, #fff1a6); color: var(--gem-mark-highlight-ink, #1f1d18); padding-inline: 0.12em; border-radius: 0.15em; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
1037
+ :where(.gem-mark--accent) { color: var(--gem-color-accent); }
1038
+ :where(.gem-mark--alert) { color: var(--gem-color-danger, #b42318); }
1039
+ :where(.gem-mark--highlight.gem-mark--accent), :where(.gem-mark--highlight.gem-mark--alert) { color: var(--gem-mark-highlight-ink, #1f1d18); }
1040
+ .gem-mark-link { color: var(--gem-color-accent); text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }
1041
+ .gem-mark-link:hover { text-decoration-thickness: 2px; }
1042
+ .gem-mark-link.gem-mark--highlight, .gem-mark--highlight .gem-mark-link { color: var(--gem-mark-highlight-ink, #1f1d18); }
1032
1043
  @keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }
1033
1044
  @keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
1034
1045
  @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 [];
@@ -700,7 +746,15 @@ var gemFrameworkPrimitiveGroups = [
700
746
  "gem-pull-quote",
701
747
  "gem-byline",
702
748
  "gem-disclaimer",
703
- "gem-subhead"
749
+ "gem-subhead",
750
+ // Nhấn trong câu (gap #39): lớp @gemtheme/gem-sections inline-marks.ts bảo người gọi vẽ, GEM_INLINE_MARK_CSS tô.
751
+ // Không khai thì section vẽ đúng dấu của framework vẫn bị gem_framework_unknown_gem_class (review #740).
752
+ "gem-mark",
753
+ "gem-mark--underline",
754
+ "gem-mark--highlight",
755
+ "gem-mark--accent",
756
+ "gem-mark--alert",
757
+ "gem-mark-link"
704
758
  ]
705
759
  },
706
760
  {
@@ -1192,6 +1246,25 @@ var GEM_REVEAL_CSS_LINES = [
1192
1246
  "@media (prefers-reduced-motion: reduce) { [data-gem-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; } }"
1193
1247
  ];
1194
1248
  var GEM_REVEAL_CSS = GEM_REVEAL_CSS_LINES.join("\n");
1249
+ var GEM_IMG_IN_CSS_LINES = [
1250
+ ":where(img[data-gem-bound-img-in][data-gem-img-in-state='loading']) { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; }",
1251
+ "@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)); } }",
1252
+ "@keyframes gem-img-in { from { filter: blur(var(--gem-motion-blur, 8px)); opacity: 0.6; } }"
1253
+ ];
1254
+ var GEM_IMG_IN_CSS = GEM_IMG_IN_CSS_LINES.join("\n");
1255
+ var GEM_INLINE_MARK_CSS_LINES = [
1256
+ ":where(.gem-mark--underline) { text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }",
1257
+ ":where(.gem-mark--highlight) { background: var(--gem-mark-highlight, #fff1a6); color: var(--gem-mark-highlight-ink, #1f1d18); padding-inline: 0.12em; border-radius: 0.15em; -webkit-box-decoration-break: clone; box-decoration-break: clone; }",
1258
+ ":where(.gem-mark--accent) { color: var(--gem-color-accent); }",
1259
+ ":where(.gem-mark--alert) { color: var(--gem-color-danger, #b42318); }",
1260
+ ":where(.gem-mark--highlight.gem-mark--accent), :where(.gem-mark--highlight.gem-mark--alert) { color: var(--gem-mark-highlight-ink, #1f1d18); }",
1261
+ ".gem-mark-link { color: var(--gem-color-accent); text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }",
1262
+ ".gem-mark-link:hover { text-decoration-thickness: 2px; }",
1263
+ // Bút dạ thắng màu link: chữ trên nền vàng giữ mực đậm, dù span nằm trong link hay link nằm trong span (review #740 đo màu nhấn
1264
+ // trên vàng 4,32:1 ở nền trắng, 2,47:1 trên băng tối, 1,05:1 trên băng màu nhấn).
1265
+ ".gem-mark-link.gem-mark--highlight, .gem-mark--highlight .gem-mark-link { color: var(--gem-mark-highlight-ink, #1f1d18); }"
1266
+ ];
1267
+ var GEM_INLINE_MARK_CSS = GEM_INLINE_MARK_CSS_LINES.join("\n");
1195
1268
  var GEM_AMBIENT_CSS_LINES = [
1196
1269
  "@keyframes gem-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(calc(-1 * var(--gem-ambient-amp, 7px))); } }",
1197
1270
  "@keyframes gem-sheen { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }",
@@ -2356,6 +2429,8 @@ function createPrimitiveCss() {
2356
2429
  " opacity: 1;",
2357
2430
  "}",
2358
2431
  ...GEM_REVEAL_CSS_LINES,
2432
+ ...GEM_IMG_IN_CSS_LINES,
2433
+ ...GEM_INLINE_MARK_CSS_LINES,
2359
2434
  ...GEM_AMBIENT_CSS_LINES,
2360
2435
  ...GEM_DIVIDER_CSS_LINES,
2361
2436
  ...GEM_CARD_CSS_LINES,
@@ -4403,6 +4478,8 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
4403
4478
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
4404
4479
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
4405
4480
  GEM_FOCUS_OFFSET_CSS_VAR.split(":")[0].trim(),
4481
+ // Thang chuyển động "Gọn" (tokens.ts GEM_MOTION_SCALE_DEFAULTS, #32): luôn phát, section đọc được.
4482
+ ...toMotionCssVars(GEM_MOTION_SCALE_DEFAULTS).map((line) => line.split(":")[0].trim()),
4406
4483
  // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
4407
4484
  ...GEM_DERIVED_SPACE_CSS_VARIABLES,
4408
4485
  // Extension points a section SETS (gemtheme #650): icon size, drawer slide distance.
package/dist/sections.js CHANGED
@@ -104,9 +104,164 @@ function defaultPlan(plans) {
104
104
  if (!plans.length) return null;
105
105
  return plans.reduce((best, p) => p.amount < best.amount ? p : best, plans[0]);
106
106
  }
107
+
108
+ // ../../packages/gem-sections/src/inline-marks.ts
109
+ var MARK_KINDS = ["bold", "italic", "underline", "highlight", "accent", "alert"];
110
+ var MAX_PHRASES = 12;
111
+ var MAX_PHRASE = 160;
112
+ var MAX_DEPTH = 3;
113
+ var MAX_INLINE = 2e4;
114
+ function safeMarkHref(href) {
115
+ if (typeof href !== "string") return void 0;
116
+ const h = href.trim();
117
+ return /^(https?:\/\/[^\s\\"<>]+|\/(?![/\\])[^\s\\"<>]*|#[^\s\\"<>]*)$/i.test(h) ? h : void 0;
118
+ }
119
+ function markPhrases(value) {
120
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split("\n") : [];
121
+ return raw.filter((p) => typeof p === "string").map((p) => p.trim()).filter((p) => p && p.length <= MAX_PHRASE).slice(0, MAX_PHRASES);
122
+ }
123
+ function linkPhrases(value) {
124
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split("\n") : [];
125
+ const out = [];
126
+ for (const item of raw) {
127
+ let phrase, href;
128
+ if (typeof item === "string") {
129
+ const at = item.lastIndexOf("|");
130
+ if (at < 0) continue;
131
+ phrase = item.slice(0, at);
132
+ href = item.slice(at + 1);
133
+ } else if (item && typeof item === "object") {
134
+ phrase = item.text;
135
+ href = item.href;
136
+ }
137
+ const p = typeof phrase === "string" ? phrase.trim() : "";
138
+ const h = safeMarkHref(href);
139
+ if (p && p.length <= MAX_PHRASE && h) out.push({ phrase: p, href: h });
140
+ if (out.length >= MAX_PHRASES) break;
141
+ }
142
+ return out;
143
+ }
144
+ var sameMarks = (a, b) => a.href === b.href && a.marks.length === b.marks.length && a.marks.every((m, i) => m === b.marks[i]);
145
+ var ordered = (marks) => MARK_KINDS.filter((k) => new Set(marks).has(k));
146
+ function merge(runs) {
147
+ const out = [];
148
+ for (const r of runs) {
149
+ if (!r.text) continue;
150
+ const last = out[out.length - 1];
151
+ if (last && sameMarks(last, r)) last.text += r.text;
152
+ else out.push({ text: r.text, marks: [...r.marks], ...r.href ? { href: r.href } : {} });
153
+ }
154
+ return out.length ? out : [{ text: "", marks: [] }];
155
+ }
156
+ function place(text, phrases) {
157
+ const taken = [];
158
+ phrases.forEach((phrase, index) => {
159
+ for (let at = text.indexOf(phrase); at >= 0; at = text.indexOf(phrase, at + 1)) {
160
+ const end = at + phrase.length;
161
+ if (!taken.some(([s, e]) => at < e && s < end)) {
162
+ taken.push([at, end, index]);
163
+ break;
164
+ }
165
+ }
166
+ });
167
+ return taken;
168
+ }
169
+ function phraseMarkRuns(text, lists = {}) {
170
+ const copy = String(text ?? "");
171
+ const n = copy.length;
172
+ const marks = Array.from({ length: n });
173
+ const hrefs = Array.from({ length: n });
174
+ let any = false;
175
+ for (const kind of MARK_KINDS) {
176
+ for (const [s, e] of place(copy, markPhrases(lists[kind]))) {
177
+ for (let i = s; i < e; i++) (marks[i] ??= /* @__PURE__ */ new Set()).add(kind);
178
+ any = true;
179
+ }
180
+ }
181
+ const links = linkPhrases(lists.links);
182
+ for (const [s, e, index] of place(copy, links.map((l) => l.phrase))) {
183
+ for (let i = s; i < e; i++) hrefs[i] = links[index].href;
184
+ any = true;
185
+ }
186
+ if (!any) return [{ text: copy, marks: [] }];
187
+ const runs = [];
188
+ for (let i = 0; i < n; i++) runs.push({ text: copy[i], marks: ordered(marks[i] ?? []), ...hrefs[i] ? { href: hrefs[i] } : {} });
189
+ return merge(runs);
190
+ }
191
+ var NS = "(?=[^\\s*])";
192
+ var NE = "(?<=[^\\s*])";
193
+ var INLINE = new RegExp(
194
+ [
195
+ "\\[[^\\[\\]\\n]+\\]\\((?:[^()\\s]|\\([^()\\s]*\\))+\\)",
196
+ `\\*\\*\\*${NS}[^*\\n]+?${NE}\\*\\*\\*(?!\\*)`,
197
+ `\\*\\*${NS}(?:[^*\\n]|\\*\\*\\*[^*\\n]+?\\*\\*\\*|\\*(?!\\*))+?${NE}\\*\\*(?!\\*)|\\*\\*${NS}[^*\\n]*?\\*${NS}[^*\\n]+?${NE}\\*\\*\\*(?!\\*)`,
198
+ `\\*${NS}(?:[^*\\n]|\\*\\*\\*[^*\\n]+?\\*\\*\\*|\\*\\*[^*\\n]+?\\*\\*)+?${NE}\\*(?!\\*)`,
199
+ "==(?=[^\\s=])[^=\\n]+?(?<=[^\\s=])==",
200
+ "(?<![\\p{L}\\p{N}_])__(?=[^\\s_])[^_\\n]+?(?<=[^\\s_])__(?![\\p{L}\\p{N}_])",
201
+ "!!(?=[^\\s!])[^!\\n]+?(?<=[^\\s!])!!",
202
+ "\\^\\^(?=[^\\s^])[^\\^\\n]+?(?<=[^\\s^])\\^\\^"
203
+ ].join("|"),
204
+ "gu"
205
+ );
206
+ var OPEN = [["***", ["bold", "italic"]], ["**", ["bold"]], ["==", ["highlight"]], ["__", ["underline"]], ["!!", ["alert"]], ["^^", ["bold", "accent"]], ["*", ["italic"]]];
207
+ var ESCAPABLE = "*_=!^[]()\\";
208
+ var STAND_IN = 57344;
209
+ var escapeMarkers = (src) => src.replace(/\\([*_=!^[\]()\\])/g, (_, c) => String.fromCharCode(STAND_IN + ESCAPABLE.indexOf(c)));
210
+ var unescapeMarkers = (text) => text.replace(/[-]/g, (c) => ESCAPABLE[c.charCodeAt(0) - STAND_IN]);
211
+ function read(src, inherited, href, depth) {
212
+ const out = [];
213
+ const at = (text, marks, h = href) => ({ text, marks: ordered(marks), ...h ? { href: h } : {} });
214
+ let last = 0;
215
+ for (const m of src.matchAll(INLINE)) {
216
+ const i = m.index ?? 0;
217
+ if (i > last) out.push(at(src.slice(last, i), inherited));
218
+ const tok = m[0];
219
+ if (tok.startsWith("[")) {
220
+ const lm = /^\[([^\]]+)\]\((.+)\)$/.exec(tok);
221
+ const safe = lm && !href ? safeMarkHref(unescapeMarkers(lm[2])) : void 0;
222
+ if (lm && safe && depth < MAX_DEPTH) out.push(...read(lm[1], inherited, safe, depth + 1));
223
+ else out.push(at(lm ? lm[1] : tok, inherited));
224
+ } else {
225
+ const [open, add] = OPEN.find(([o]) => tok.startsWith(o) && tok.endsWith(o) && tok.length > 2 * o.length) ?? OPEN.find(([o]) => tok.startsWith(o) && tok.length > 2 * o.length) ?? ["", []];
226
+ const close = tok.endsWith(open) ? open.length : 0;
227
+ const inner = open ? tok.slice(open.length, tok.length - close) : tok;
228
+ const marks = [...inherited, ...add];
229
+ if (open && depth < MAX_DEPTH) out.push(...read(inner, marks, href, depth + 1));
230
+ else out.push(at(open ? inner : tok, marks));
231
+ }
232
+ last = i + tok.length;
233
+ }
234
+ if (last < src.length) out.push(at(src.slice(last), inherited));
235
+ return out;
236
+ }
237
+ function inlineMarkRuns(src) {
238
+ const text = String(src ?? "");
239
+ const escapes = !/[\ue000-\ue009]/.test(text);
240
+ const runs = [];
241
+ text.split("\n").forEach((line, i) => {
242
+ if (i) runs.push({ text: "\n", marks: [] });
243
+ if (line.length > MAX_INLINE) return void runs.push({ text: line, marks: [] });
244
+ const read1 = read(escapes ? escapeMarkers(line) : line, [], void 0, 0);
245
+ runs.push(...escapes ? read1.map((r) => ({ ...r, text: unescapeMarkers(r.text) })) : read1);
246
+ });
247
+ return merge(runs);
248
+ }
249
+ function markClassName(marks) {
250
+ const span = marks.filter((m) => m !== "bold" && m !== "italic");
251
+ return span.length ? ["gem-mark", ...span.map((m) => `gem-mark--${m}`)].join(" ") : "";
252
+ }
253
+ function inlineMarkText(src) {
254
+ return inlineMarkRuns(src).map((r) => r.text).join("");
255
+ }
107
256
  export {
257
+ MARK_KINDS,
108
258
  defaultPlan,
109
259
  elementOverrides,
110
260
  imageSrcSet,
261
+ inlineMarkRuns,
262
+ inlineMarkText,
263
+ markClassName,
264
+ phraseMarkRuns,
265
+ safeMarkHref,
111
266
  subscriptionPlans
112
267
  };
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.15.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",