@gemboss/website-ui 0.7.0 → 0.8.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/AGENTS.md +13 -3
- package/CHANGELOG.md +69 -0
- package/README.md +8 -8
- package/bin/lib/check.mjs +53 -1
- package/css/header.css +176 -0
- package/demo/header.html +356 -0
- package/demo/index.html +1 -0
- package/docs/components.md +69 -4
- package/docs/markup-contract.md +102 -5
- package/docs/theming.md +3 -2
- package/js/website.js +118 -4
- package/js/website.mjs +145 -4
- package/package.json +1 -1
- package/skill/SKILL.md +4 -0
- package/templates/header.html +34 -6
- package/website.css +177 -1
- package/website.min.css +1 -1
package/AGENTS.md
CHANGED
|
@@ -10,10 +10,11 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
|
|
|
10
10
|
| file | what it is |
|
|
11
11
|
|---|---|
|
|
12
12
|
| `website.css` | the whole kit, one file (tokens, base, layout, components, sections) |
|
|
13
|
-
| `js/website.js` | header, mega menu, mobile menu, theme toggle, scroll reveal, segmented control, busy buttons, an article's contents and its tables' scroll box, the footer's folds; no dependencies; `window.gwWebsite.init(root)` |
|
|
13
|
+
| `js/website.js` | header, mega menu, mobile menu (and its drill-down), language switcher, theme toggle, scroll reveal, segmented control, busy buttons, an article's contents and its tables' scroll box, the footer's folds; no dependencies; `window.gwWebsite.init(root)` |
|
|
14
14
|
| `js/website.mjs` | the same script as an ES module for apps: `import { init, destroy } from '@gemboss/website-ui/website.mjs'` |
|
|
15
15
|
| `system.json` | every value of the system: colours, spacing, type, radii, shadows, widths |
|
|
16
16
|
| `templates/page.html` | the page skeleton: start every new page from it |
|
|
17
|
+
| `templates/header.html` | the header of every site, with its two options (the drill-down mobile menu, the language switcher): fill it once, in the site's shared partial |
|
|
17
18
|
| `templates/footer.html` | the footer of every site (the footer of gemboss.ai): fill it once, in the site's shared partial |
|
|
18
19
|
| `templates/sections/*.html` | ready sections: hero, features, split, showcase, logos, stats, steps, pricing, FAQ, CTA, prose, article, post list, a short form, testimonials… |
|
|
19
20
|
| `demo/index.html` | every component on one page: open it to see what exists before you build anything |
|
|
@@ -21,6 +22,7 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
|
|
|
21
22
|
| `demo/article.html`, `demo/blog.html` | a whole blog post (breadcrumbs, meta, contents, prose, callouts, FAQ) and the blog index (post list, pagination) |
|
|
22
23
|
| `demo/forms.html` | fields in every state, choices, the footer newsletter, a short form, stars, the testimonial wall and the rating badge |
|
|
23
24
|
| `demo/footer.html` | the full footer (Ask AI, columns that fold on phones, family rows, theme toggle, wordmark), and the same for a GemPages-style brand |
|
|
25
|
+
| `demo/header.html` | the header of gemboss.ai: the mobile menu that drills down with its bar of actions, and the language switcher, and the same for a GemPages-style brand |
|
|
24
26
|
| `docs/components.md` | every class, what it is for, its variants |
|
|
25
27
|
| `docs/theming.md` | what a brand other than GemBoss chooses (accent, wash, logo, three named profiles), and what it must not change |
|
|
26
28
|
|
|
@@ -101,6 +103,11 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
|
|
|
101
103
|
`<summary>`: rows that fold on phones, open columns from 768px, no script needed. A link that leaves the site gets the
|
|
102
104
|
↗ (`.gw-footer__ext`); one with `target="_blank"` also `<span class="gw-visually-hidden"> (opens in a new tab)</span>`.
|
|
103
105
|
The wordmark is `aria-hidden="true"`.
|
|
106
|
+
- **Header** (0.8.0): `templates/header.html`. Two options. The mobile menu drills down (`data-gw-menu="drill"`): each
|
|
107
|
+
group carries its head, `<button class="gw-mobile-menu__back">` and `<h2 class="gw-mobile-menu__title" tabindex="-1">`
|
|
108
|
+
in the page's language, and the actions sit in a bar at the bottom. The language switcher (`.gw-lang`, kept `hidden`,
|
|
109
|
+
its list empty) lists the page's `<link rel="alternate" hreflang>` from `<head>` and shows itself when there is
|
|
110
|
+
another language; its word for "Language" goes in `data-gw-label-language`.
|
|
104
111
|
- **Ratings** (0.6.0): stars `.gw-stars` (`role="img"`, `aria-label="4.9 out of 5"`, `style="--gw-stars: 4.9"`), the
|
|
105
112
|
wall `.gw-testimonials` of `.gw-card.gw-testimonial`, the badge `.gw-rating-badge` (`templates/sections/testimonials.html`).
|
|
106
113
|
- **One choice out of two to four** (Monthly / Yearly) is `.gw-segmented`, a radio group; what each option shows
|
|
@@ -147,6 +154,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
|
|
|
147
154
|
- Don't make a callout whose kind only its colour tells: `.gw-callout` starts with its `.gw-callout__label` in words.
|
|
148
155
|
- Don't type separators (›, /, >) between breadcrumbs, and don't build a contents list whose links point at ids the
|
|
149
156
|
page does not have: build it from the article's own h2s.
|
|
157
|
+
- Don't build your own language menu or phone menu, don't type the switcher's links (it reads `<link rel="alternate"
|
|
158
|
+
hreflang>`), and don't show it on a page that has one language. Don't leave Back in English on a translated page.
|
|
150
159
|
- Don't add a footer part the template does not have, and don't build footer columns that fold with your own script:
|
|
151
160
|
fill `templates/footer.html` (its family rows take another brand's siblings: GemBoss, GemX, GemUpsells on a
|
|
152
161
|
GemPages site). Don't put the assistants' logos in the Ask AI row.
|
|
@@ -262,8 +271,9 @@ a waiting button not marked with `aria-busy`, a segmented control without its ra
|
|
|
262
271
|
with numbers and no caption, an unknown brand profile or one set off `<html>`, `.gw-header--float` off the header,
|
|
263
272
|
words on the accent button below 4.5:1, breadcrumbs without their markup, a contents link to an id that is not on
|
|
264
273
|
the page, a callout without its label, stars without their value in words, a rating or a testimonial without a link to
|
|
265
|
-
its source, a footer fold without its heading in the summary or a wordmark a screen reader reads,
|
|
266
|
-
|
|
274
|
+
its source, a footer fold without its heading in the summary or a wordmark a screen reader reads, a drill-down level
|
|
275
|
+
without its Back or its title, a language switcher shown by the markup or a translation without `hreflang`, and
|
|
276
|
+
patterns that repeat across pages (likely gaps). It does not replace looking at the page at 320px and 1366px.
|
|
267
277
|
|
|
268
278
|
## 11. Upgrading the kit
|
|
269
279
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,75 @@ Every release lists what changed for a site that uses the kit. A release that ad
|
|
|
4
4
|
says so with `Closes gap #N` (the GitHub issue number), so `npx gemboss-website upgrade` can tell that site which
|
|
5
5
|
stopgap (`kitgap-*`) it can now delete.
|
|
6
6
|
|
|
7
|
+
## 0.8.0 (2026-10-25)
|
|
8
|
+
|
|
9
|
+
The header of gemboss.ai on a phone, so the site's header can move onto the kit: a mobile menu that drills down into
|
|
10
|
+
each section with its actions in a bar at the bottom, and the language switcher. Both are options: a header without them
|
|
11
|
+
looks and works exactly as before. The demo index (without its new link), `brand-profiles.html`, `article.html`,
|
|
12
|
+
`blog.html`, `forms.html`, `footer.html` and `theme-example.html` are pixel-identical to 0.7.0 at 390 and 1366px, light
|
|
13
|
+
and dark.
|
|
14
|
+
|
|
15
|
+
- Drill-down mobile menu, opt-in: `data-gw-menu="drill"` on `.gw-mobile-menu`, as gemboss.ai's phone menu (v4, approved
|
|
16
|
+
15/10/2026). The markup is the 0.7.0 menu, plus a head in each group: `<div class="gw-mobile-menu__head">` holding
|
|
17
|
+
`<button class="gw-mobile-menu__back" type="button">Back</button>` and `<h2 class="gw-mobile-menu__title"
|
|
18
|
+
tabindex="-1">`, in the page's language. The top level lists the sections in 64px rows (24px words, a chevron that
|
|
19
|
+
points right); a press on one shows its level alone, with Back on the left and its title in the middle; a level may
|
|
20
|
+
carry headings over runs of links (`.gw-mobile-menu__heading`), a 32px icon tile and a line under a link's name
|
|
21
|
+
(`.gw-mobile-menu__icon`, `<b>`, `<i>`), and a last link to the section's own page (`.gw-mobile-menu__all`, in the
|
|
22
|
+
accent, with its arrow). The actions sit in a bar that stays at the bottom of the sheet while the list scrolls: what is
|
|
23
|
+
not a button on its first line (the language on the left, Sign in on the right), the two buttons side by side under it
|
|
24
|
+
(`gw-btn--lg`: 48px), one hairline above, the safe area below.
|
|
25
|
+
Closes gap #41
|
|
26
|
+
- Its script, wired like every other part (one record, `init` once per element, `destroy` takes it back): a press on a
|
|
27
|
+
section (tap, click, Enter, Space) opens its level and moves the focus to the level's title; Back shows the top level
|
|
28
|
+
and puts the focus back on the section; Escape closes the sheet from anywhere in it, back on the top level, the focus on
|
|
29
|
+
the toggle; while it is open, Tab and Shift+Tab go round the toggle and the sheet (the focus trap); the page under it
|
|
30
|
+
is locked (as in 0.7.0); the sheet starts where the header ends (measured as it opens, `--gw-menu-top`) and the header
|
|
31
|
+
stays in place, so an announcement bar above it no longer jumps away. A level slides in from the right (0.24s, the
|
|
32
|
+
kit's ease, transform and opacity), the sections slide back from the left; under `prefers-reduced-motion` they switch
|
|
33
|
+
at once. Without the script (or before `init`) the groups are the 0.7.0 `<details>` that open in place. `.is-drill`
|
|
34
|
+
(wired), `.is-drilled` (a level in view) and `.is-back` (the slide back) are the script's states.
|
|
35
|
+
- `gw:menu`, a new event on the menu (`.gw-mobile-menu`), `detail: { open, level }`: `level` is the title of the level
|
|
36
|
+
in view, `''` for the top level. It fires when a menu opens or closes (every mobile menu, the 0.7.0 one too) and when
|
|
37
|
+
a drill-down menu moves to a level or back; closing from a level says only `{ open: false, level: '' }`.
|
|
38
|
+
- Language switcher `.gw-lang` (`data-gw-lang`, `hidden` in the markup): a ghost button with the globe and the current
|
|
39
|
+
language's short code (`.gw-lang__btn`, `.gw-lang__short`), and the list of the other languages (`.gw-lang__menu`), a
|
|
40
|
+
small popover card with the card radius of the site's profile. `website.js` fills the list from the page's own
|
|
41
|
+
`<link rel="alternate" hreflang href>` (not `x-default`): the links search engines read, so the switcher offers
|
|
42
|
+
exactly the translations they are told about, and a page with no other language keeps the switcher hidden (gemboss.ai
|
|
43
|
+
today, while `liveLangs` is empty). The current language is the one whose `hreflang` is `<html lang>` (else the same
|
|
44
|
+
base language: `zh` for `zh-Hans`). Each other one is a real link (`.gw-lang__link`, with `hreflang` and `lang`), named
|
|
45
|
+
in its own language: the `<link>`'s `title`, else the kit's name (English, Tiếng Việt, 中文), else the browser's. A link
|
|
46
|
+
to the host of `<link rel="canonical">` keeps only its path, so a preview deployment stays on itself. Every `init`
|
|
47
|
+
reads the links again (a route change). Keyboard: the button opens and closes it; ArrowDown and ArrowUp on it open the
|
|
48
|
+
list on its first or last link, move in it (wrapping), Home and End; Escape closes it with the focus back on the
|
|
49
|
+
button, and goes no further; focus leaving it or a click outside closes it. Its words come from the markup:
|
|
50
|
+
`data-gw-label-language` (else the button's `aria-label`, else "Language") names the button "Language: English" and
|
|
51
|
+
the list "Language". In the header's actions it shows from 1080px; below, a second one sits in the mobile menu's bar
|
|
52
|
+
and its list opens upward. No new dependency: it replaces loading `@gemboss/ui`'s plain switcher next to the kit.
|
|
53
|
+
Closes gap #29
|
|
54
|
+
- Templates: `templates/header.html` shows both options, with comments: the drill-down menu (and how to leave it out),
|
|
55
|
+
the switcher in the actions and in the bar. `templates/page.html` keeps the 0.7.0 menu.
|
|
56
|
+
- Demo: `demo/header.html`, the header of gemboss.ai with its words, sections, links and icons, the drill-down menu, an
|
|
57
|
+
announcement bar, and the switcher with three languages (en, vi, zh-Hans) whose alternates point at the demo itself
|
|
58
|
+
(`?hl=`), with a switch to the same header for a GemPages-style brand (soft corners, the accent button, the floating
|
|
59
|
+
header from 1080px, GemPages' blue). Linked from `demo/index.html`.
|
|
60
|
+
- `check`: `menu-markup` (an error): in a `data-gw-menu="drill"` menu, a group that is not a `<details>`, has no
|
|
61
|
+
`<summary>`, no back button, no title, or a title without `tabindex="-1"`; `data-gw-menu` with another value.
|
|
62
|
+
`lang-markup` (an error): a `.gw-lang` not `hidden` in the markup, without `data-gw-lang`, without its button, a button
|
|
63
|
+
naming no list inside it or without `aria-expanded`, links typed into its list; and, on a page with a switcher, a
|
|
64
|
+
translation's `<link rel="alternate">` (no `type`) without `hreflang` or `href`.
|
|
65
|
+
- `AGENTS.md`, the skill and the docs (`components.md`, `markup-contract.md`): the two options, their markup, the
|
|
66
|
+
script's behaviour and `gw:menu`.
|
|
67
|
+
- Budget: `website.min.css` is 71.8 KB, 12.2 KB gzipped (0.7.0: 65.8 KB, 11.4 KB). `js/website.js` is 20.8 KB
|
|
68
|
+
unminified, 6.4 KB gzipped (0.7.0: 14.5 KB, 4.7 KB): the test's budget is now 22 KB and 7 KB.
|
|
69
|
+
- Tests: `test/run.mjs` holds the 0.7.0 rules of `header.css` byte for byte and every 0.8.0 rule to a 0.8.0 part, and
|
|
70
|
+
covers the drill-down menu, the trap, `gw:menu` and the switcher in its fake DOM (which now sets text, has
|
|
71
|
+
`getClientRects`, style properties, `URL` and `Intl`) and the two rules; `test/browser.mjs` drives `demo/header.html`
|
|
72
|
+
in Chromium (by touch and by keyboard, the focus, Escape, the trap, the scroll lock, the bar at the bottom of a long
|
|
73
|
+
level, reduced motion, without the script, the switcher shown and hidden by the head's links, its keys and a real
|
|
74
|
+
switch to the Vietnamese page, init twice, destroy then init, 320 and 390px light and dark, the GemPages-style switch).
|
|
75
|
+
|
|
7
76
|
## 0.7.0 (2026-10-25)
|
|
8
77
|
|
|
9
78
|
The footer as gemboss.ai has it today (gap #71): the kit's footer, built on 24/10 from gemboss.ai, had fallen behind
|
package/README.md
CHANGED
|
@@ -12,11 +12,11 @@ it. Made so that a team working with Claude Code, even without a designer, ships
|
|
|
12
12
|
|
|
13
13
|
| | |
|
|
14
14
|
|---|---|
|
|
15
|
-
| `website.css`, `website.min.css` | toàn bộ kit trong một tệp (khoảng
|
|
16
|
-
| `js/website.js`, `js/website.mjs` | mega menu, menu điện thoại, đổi giao diện sáng/tối, hiện dần khi cuộn, đánh dấu trang hiện tại, segmented control, chặn bấm lần hai khi nút đang chờ, đánh dấu mục đang đọc trong mục lục bài viết, khung cuộn cho bảng trong bài, cột footer gập/mở theo khổ màn; không phụ thuộc thư viện nào, khoảng 4
|
|
15
|
+
| `website.css`, `website.min.css` | toàn bộ kit trong một tệp (khoảng 101 KB, 72 KB bản min, 12,2 KB khi nén gzip): token sáng và tối, chữ, bố cục, nút (cả trạng thái nhấn, đang chờ và nút xoá), link, nhãn, chip, segmented control, badge, card (kèm ảnh trên đầu thẻ), khung cửa sổ kèm chú thích "Illustrative data.", bảng, FAQ, chữ bài dài (`.gw-prose`), bộ khung bài blog (breadcrumb, dòng tác giả và ngày, mục lục dính, hộp lưu ý, danh sách bài, phân trang), ô nhập và trạng thái lỗi (ô chữ, danh sách chọn, ô nhiều dòng, checkbox, radio, hàng một ô một nút cho newsletter), sao đánh giá, tường review, huy hiệu điểm App Store, header + mega menu + menu điện thoại (tuỳ chọn đi sâu từng mục kèm thanh nút dính đáy, như gemboss.ai) + nút đổi ngôn ngữ, footer như gemboss.ai (cột gập lại trên điện thoại, hàng Ask AI, hàng sản phẩm cùng nhà, nút sáng/tối, wordmark; ô newsletter tuỳ chọn), 16 section, 3 hồ sơ thương hiệu |
|
|
16
|
+
| `js/website.js`, `js/website.mjs` | mega menu, menu điện thoại (kể cả bản đi sâu từng mục: Back, giữ focus trong menu, sự kiện `gw:menu`), nút đổi ngôn ngữ (đọc `<link rel="alternate" hreflang>` của trang, ẩn khi trang chỉ có một ngôn ngữ), đổi giao diện sáng/tối, hiện dần khi cuộn, đánh dấu trang hiện tại, segmented control, chặn bấm lần hai khi nút đang chờ, đánh dấu mục đang đọc trong mục lục bài viết, khung cuộn cho bảng trong bài, cột footer gập/mở theo khổ màn; không phụ thuộc thư viện nào, khoảng 6,4 KB khi nén gzip. `website.js` là script thường (tự chạy khi trang tải xong), `website.mjs` là bản ES module cho app; cả hai có `init(root)` để gắn lại phần markup chèn sau (`docs/markup-contract.md`, mục "Script") |
|
|
17
17
|
| `AGENTS.md` | luật mà Claude Code (và người) phải theo: 10 luật, cách dựng trang, điều cấm, chữ, khả năng truy cập, đổi thương hiệu, khi kit thiếu |
|
|
18
|
-
| `templates/` | khung một trang, header, footer (`footer.html`: footer của gemboss.ai, mọi phần tuỳ chọn đều ghi rõ) và 16 section (hero, logos, stats, features, split, showcase, steps, quote, pricing, faq, cta, prose, article, post-list, form, testimonials) |
|
|
19
|
-
| `demo/` | mọi thành phần trên một trang; `theme-example.html` cho thấy một thương hiệu khác đổi màu nhấn; `brand-profiles.html` là một thương hiệu kiểu GemPages bật cả 3 hồ sơ; `article.html` là một bài blog trọn vẹn, `blog.html` là trang danh sách bài; `forms.html` là mọi trạng thái của ô nhập, newsletter ở footer, tường review và huy hiệu điểm (dữ liệu mẫu); `footer.html` là footer đầy đủ như gemboss.ai, kèm nút chuyển sang bản kiểu GemPages |
|
|
18
|
+
| `templates/` | khung một trang, header (`header.html`: hai tuỳ chọn, menu điện thoại đi sâu và nút ngôn ngữ), footer (`footer.html`: footer của gemboss.ai, mọi phần tuỳ chọn đều ghi rõ) và 16 section (hero, logos, stats, features, split, showcase, steps, quote, pricing, faq, cta, prose, article, post-list, form, testimonials) |
|
|
19
|
+
| `demo/` | mọi thành phần trên một trang; `theme-example.html` cho thấy một thương hiệu khác đổi màu nhấn; `brand-profiles.html` là một thương hiệu kiểu GemPages bật cả 3 hồ sơ; `article.html` là một bài blog trọn vẹn, `blog.html` là trang danh sách bài; `forms.html` là mọi trạng thái của ô nhập, newsletter ở footer, tường review và huy hiệu điểm (dữ liệu mẫu); `footer.html` là footer đầy đủ như gemboss.ai, kèm nút chuyển sang bản kiểu GemPages; `header.html` là header của gemboss.ai trên điện thoại (menu đi sâu từng mục, thanh nút dính đáy, nút đổi ngôn ngữ với ba ngôn ngữ), cũng có bản kiểu GemPages |
|
|
20
20
|
| `docs/` | danh mục class (`components.md`), đổi thương hiệu (`theming.md`), triết lý (`philosophy.md`), hợp đồng markup |
|
|
21
21
|
| `system.json` | mọi giá trị của hệ thống (màu, khoảng cách, chữ, bo góc, bóng, khung): nguồn duy nhất |
|
|
22
22
|
| CLI `gemboss-website` | `init`, `check`, `gap`, `upgrade` |
|
|
@@ -40,9 +40,9 @@ nó đọc `AGENTS.md` của kit trước, dựng từ mẫu, và chạy `check`
|
|
|
40
40
|
**Site HTML tĩnh hoặc không có bước build:** dùng CDN của npm, ghim đúng phiên bản.
|
|
41
41
|
|
|
42
42
|
```html
|
|
43
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.
|
|
43
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.8.0/website.min.css">
|
|
44
44
|
<link rel="stylesheet" href="/site.css"> <!-- CSS riêng của site, luôn SAU kit -->
|
|
45
|
-
<script src="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.
|
|
45
|
+
<script src="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.8.0/js/website.js" defer></script>
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Chèn markup của kit sau khi trang đã tải (block của builder, bản xem trước CMS): gọi `gwWebsite.init(phầnMới)` ngay
|
|
@@ -113,7 +113,7 @@ viết hoa vẫn cấm. Công thức và các phép kiểm tương phản: `docs
|
|
|
113
113
|
| lệnh | làm gì |
|
|
114
114
|
|---|---|
|
|
115
115
|
| `npx gemboss-website init` | nối luật của kit vào Claude Code của dự án (CLAUDE.md + skill) |
|
|
116
|
-
| `npx gemboss-website check` | kiểm mọi `.html` và `.css` của site: màu, khoảng cách, chữ, bo góc ngoài thang, bóng, nút tự tô màu, nhãn viết hoa giãn chữ, đè class `gw-`, đổi token của kit, hồ sơ thương hiệu sai tên hoặc sai chỗ, chữ trên nút màu nhấn dưới 4,5:1, dấu gạch dài, ảnh thiếu `alt`, link mơ hồ, sao thiếu giá trị bằng chữ, điểm hay review không có link nguồn, cột footer gập thiếu tiêu đề trong summary, wordmark không ẩn với trình đọc màn hình
|
|
116
|
+
| `npx gemboss-website check` | kiểm mọi `.html` và `.css` của site: màu, khoảng cách, chữ, bo góc ngoài thang, bóng, nút tự tô màu, nhãn viết hoa giãn chữ, đè class `gw-`, đổi token của kit, hồ sơ thương hiệu sai tên hoặc sai chỗ, chữ trên nút màu nhấn dưới 4,5:1, dấu gạch dài, ảnh thiếu `alt`, link mơ hồ, sao thiếu giá trị bằng chữ, điểm hay review không có link nguồn, cột footer gập thiếu tiêu đề trong summary, wordmark không ẩn với trình đọc màn hình, mục của menu đi sâu thiếu nút Back hay tiêu đề, nút ngôn ngữ không ẩn sẵn hay bản dịch thiếu `hreflang`… và gợi ý chỗ kit có thể đang thiếu |
|
|
117
117
|
| `npx gemboss-website gap` | báo một chỗ kit còn thiếu: mở GitHub Issue ở repo của kit (nếu `gh` đã đăng nhập và có quyền), không thì ghi `kit-gaps/<tên>.md` để gửi |
|
|
118
118
|
| `npx gemboss-website upgrade` | so bản đang dùng với bản mới nhất, in thay đổi, gap đã có trong kit, và lệnh nâng cấp |
|
|
119
119
|
|
|
@@ -141,7 +141,7 @@ gemboss-www; mọi thay đổi đi qua PR, giá trị của hệ thống chỉ s
|
|
|
141
141
|
cd packages/website-ui
|
|
142
142
|
node scripts/build.mjs # sinh css/tokens.css từ system.json, ghép website.css và website.min.css, sinh js/website.js từ js/website.mjs
|
|
143
143
|
node test/run.mjs # tự kiểm CLI, CSS và website.js (DOM giả)
|
|
144
|
-
node test/browser.mjs # demo trong Chromium thật: bài viết (cuộn, Tab, mục lục, bảng), form, sao, review
|
|
144
|
+
node test/browser.mjs # demo trong Chromium thật: bài viết (cuộn, Tab, mục lục, bảng), form, sao, review, footer (gập, Tab, nút sáng/tối) và header (menu đi sâu bằng chạm và bàn phím, giữ focus, nút ngôn ngữ) (cần .ci của repo)
|
|
145
145
|
node scripts/gate-demo.mjs # cổng thị giác và khoảng thở của gemboss.ai trên demo (cần .ci của repo)
|
|
146
146
|
```
|
|
147
147
|
|
package/bin/lib/check.mjs
CHANGED
|
@@ -149,6 +149,16 @@ export const RULES = {
|
|
|
149
149
|
why: "A footer column folds on phones as a <details class=\"gw-footer__fold\"> whose <summary> holds the column's heading (<h2 class=\"gw-footer__heading\">): the summary is the row a reader taps, and a screen reader names the row by that heading. The wordmark (.gw-footer__wordmark) repeats the brand's name as decoration; without aria-hidden=\"true\" a screen reader reads the name a second time.",
|
|
150
150
|
fix: "Copy the markup from templates/footer.html: <details class=\"gw-footer__fold\"><summary><h2 class=\"gw-footer__heading\">Product</h2></summary><ul>…</ul></details>, and <div class=\"gw-footer__wordmark\" aria-hidden=\"true\">.",
|
|
151
151
|
},
|
|
152
|
+
"menu-markup": {
|
|
153
|
+
level: "error", title: "Drill-down menu level without its head",
|
|
154
|
+
why: "In a mobile menu with data-gw-menu=\"drill\" each <details class=\"gw-mobile-menu__group\"> is a level the reader opens on its own, from its <summary>. Its head is the only way back (a <button class=\"gw-mobile-menu__back\">) and its title (.gw-mobile-menu__title with tabindex=\"-1\") is where website.js puts the focus. Without them a phone visitor is stuck in the level, or a keyboard and a screen reader lose their place. data-gw-menu has one value: drill.",
|
|
155
|
+
fix: "Copy the group from templates/header.html: <details class=\"gw-mobile-menu__group\"><summary>Product</summary><div class=\"gw-mobile-menu__head\"><button class=\"gw-mobile-menu__back\" type=\"button\">Back</button><h2 class=\"gw-mobile-menu__title\" tabindex=\"-1\">Product</h2></div><ul class=\"gw-mobile-menu__list\">…</ul></details>, with Back in the page's language.",
|
|
156
|
+
},
|
|
157
|
+
"lang-markup": {
|
|
158
|
+
level: "error", title: "Language switcher without its markup",
|
|
159
|
+
why: "website.js fills .gw-lang from the page's <link rel=\"alternate\" hreflang href> and shows it only when there is another language: it starts hidden, its button (.gw-lang__btn) names its list with aria-controls and says aria-expanded, and the list is empty in the markup. A switcher shown by the markup is an empty button on a page with one language; links typed into the list are replaced; a translation's <link rel=\"alternate\"> without hreflang is one the switcher (and search engines) cannot list.",
|
|
160
|
+
fix: "Copy the switcher from templates/header.html: <div class=\"gw-lang\" data-gw-lang hidden><button class=\"gw-lang__btn\" type=\"button\" aria-expanded=\"false\" aria-controls=\"gw-lang-menu\">…</button><ul class=\"gw-lang__menu\" id=\"gw-lang-menu\" hidden></ul></div>, and give each translation <link rel=\"alternate\" hreflang=\"vi\" href=\"…\"> in <head>.",
|
|
161
|
+
},
|
|
152
162
|
"uppercase-text": {
|
|
153
163
|
level: "warning", title: "Uppercase text",
|
|
154
164
|
why: "Headings, buttons, labels and menu items are sentence case.",
|
|
@@ -552,7 +562,8 @@ function analyzeHtml(file, text, out) {
|
|
|
552
562
|
}
|
|
553
563
|
|
|
554
564
|
const classes = new Set();
|
|
555
|
-
const ids = new Set(), tocs = [];
|
|
565
|
+
const ids = new Set(), tocs = [], drills = [], alts = [];
|
|
566
|
+
let langs = 0;
|
|
556
567
|
let h1 = 0;
|
|
557
568
|
for (const m of scanText.matchAll(TAG_RE)) {
|
|
558
569
|
const tag = m[1].toLowerCase();
|
|
@@ -647,6 +658,40 @@ function analyzeHtml(file, text, out) {
|
|
|
647
658
|
].filter(Boolean);
|
|
648
659
|
if (missing.length) add("footer-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} missing: ${missing.join(", ")}`));
|
|
649
660
|
}
|
|
661
|
+
if (cls.includes("gw-mobile-menu") && "data-gw-menu" in a) {
|
|
662
|
+
if (a["data-gw-menu"] !== "drill") add("menu-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} missing: data-gw-menu="drill" (the one option; leave the attribute out for the 0.7.0 menu)`));
|
|
663
|
+
else drills.push([m.index, elementEnd(scanText, m.index, tag)]);
|
|
664
|
+
}
|
|
665
|
+
if (cls.includes("gw-mobile-menu__group") && drills.some(([s0, e0]) => m.index > s0 && (e0 < 0 || m.index < e0))) {
|
|
666
|
+
const body = inner();
|
|
667
|
+
const t = /<[a-z][\w-]*\b([^>]*\bclass\s*=\s*["'][^"']*\bgw-mobile-menu__title\b[^>]*)>/i.exec(body);
|
|
668
|
+
const missing = [
|
|
669
|
+
tag !== "details" && `a <details> (the class is on <${tag}>)`,
|
|
670
|
+
!/<summary\b/i.test(body) && "a <summary> (the row that opens the level)",
|
|
671
|
+
!/<button\b[^>]*\bclass\s*=\s*["'][^"']*\bgw-mobile-menu__back\b/i.test(body) && 'a back button (<button class="gw-mobile-menu__back" type="button">)',
|
|
672
|
+
!t && 'the level\'s title (<h2 class="gw-mobile-menu__title" tabindex="-1">)',
|
|
673
|
+
t && parseAttrs(t[1]).tabindex !== "-1" && 'tabindex="-1" on the title (the script puts the focus there)',
|
|
674
|
+
].filter(Boolean);
|
|
675
|
+
if (missing.length) add("menu-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} missing: ${missing.join(", ")}`));
|
|
676
|
+
}
|
|
677
|
+
if (cls.includes("gw-lang")) {
|
|
678
|
+
langs++;
|
|
679
|
+
const body = inner();
|
|
680
|
+
const b = /<button\b([^>]*\bclass\s*=\s*["'][^"']*\bgw-lang__btn\b[^>]*)>/i.exec(body);
|
|
681
|
+
const ba = b ? parseAttrs(b[1]) : {};
|
|
682
|
+
const list = ba["aria-controls"] && new RegExp(`<([a-z]+)\\b[^>]*\\bid\\s*=\\s*["']${ba["aria-controls"].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'][^>]*>`, "i").exec(body);
|
|
683
|
+
const missing = [
|
|
684
|
+
!("hidden" in a) && "hidden (the script shows it when the page has another language)",
|
|
685
|
+
!("data-gw-lang" in a) && "data-gw-lang",
|
|
686
|
+
!b && 'a <button class="gw-lang__btn" type="button">',
|
|
687
|
+
b && !ba["aria-controls"] && "aria-controls on the button, naming its list",
|
|
688
|
+
b && ba["aria-controls"] && !list && `the list the button names (id="${ba["aria-controls"]}") inside the switcher`,
|
|
689
|
+
b && !("aria-expanded" in ba) && 'aria-expanded="false" on the button',
|
|
690
|
+
/<a\b/i.test(body) && "an empty list (the script writes the links from <link rel=\"alternate\" hreflang>)",
|
|
691
|
+
].filter(Boolean);
|
|
692
|
+
if (missing.length) add("lang-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} missing: ${missing.join(", ")}`));
|
|
693
|
+
}
|
|
694
|
+
if (tag === "link" && /(?:^|\s)alternate(?:\s|$)/i.test(a.rel || "") && !a.type) alts.push({ a, where: where() });
|
|
650
695
|
if (cls.includes("gw-rating-badge") || cls.includes("gw-testimonial")) {
|
|
651
696
|
const linked = (tag === "a" && "href" in a) || /<a\b[^>]*\bhref\s*=/i.test(inner());
|
|
652
697
|
if (!linked) add("rating-source", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} ${decode(visible.slice(m.index, elementEnd(scanText, m.index, tag))).replace(/\s+/g, " ").trim().slice(0, 60)}`));
|
|
@@ -688,6 +733,13 @@ function analyzeHtml(file, text, out) {
|
|
|
688
733
|
if (!ids.has(id)) add("toc-target", { file: relFile, line: lineAt(t.start + x.index), excerpt: `href="#${x[1]}": no element on the page has id="${id}"` });
|
|
689
734
|
}
|
|
690
735
|
}
|
|
736
|
+
// A page with a switcher: each translation it links in <head> needs its hreflang and its href.
|
|
737
|
+
if (langs) {
|
|
738
|
+
for (const x of alts) {
|
|
739
|
+
const missing = [!(x.a.hreflang || "").trim() && "hreflang (the switcher lists a translation by it)", !(x.a.href || "").trim() && "href"].filter(Boolean);
|
|
740
|
+
if (missing.length) add("lang-markup", { ...x.where, excerpt: `${x.where.excerpt} missing: ${missing.join(", ")}` });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
691
743
|
out.pageClasses.push({ file: relFile, classes });
|
|
692
744
|
}
|
|
693
745
|
|
package/css/header.css
CHANGED
|
@@ -261,3 +261,179 @@ html.is-menu-open .gw-header { position: fixed; top: 0; left: 0; right: 0; }
|
|
|
261
261
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
262
262
|
.gw-header--float { background: var(--gw-surface); }
|
|
263
263
|
}
|
|
264
|
+
|
|
265
|
+
/* ---- 0.8.0: the drill-down mobile menu (gap #41) and the language switcher (gap #29) --------------------------------
|
|
266
|
+
Every rule below selects a part 0.7.0 does not have (data-gw-menu="drill", the script's .is-drill, the menu's
|
|
267
|
+
__head, __back, __title, __heading and __icon, .gw-lang), so a 0.7.0 header looks and works exactly as before. */
|
|
268
|
+
|
|
269
|
+
/* The drill-down menu, as gemboss.ai's phone menu (v4, approved 15/10/2026), opt-in on the sheet:
|
|
270
|
+
<div class="gw-mobile-menu" data-gw-menu="drill">. Its look needs only the attribute: top-level rows of 64px with
|
|
271
|
+
24px words and one hairline each, a level's links with an optional icon and line, and the actions in a bar that
|
|
272
|
+
stays at the bottom of the sheet while the list scrolls (the sheet has no bottom padding, so nothing peeks out under
|
|
273
|
+
the bar; nor sideways while a level slides in). Without the script the groups are the 0.7.0 <details> that open in
|
|
274
|
+
place, their chevron pointing down. */
|
|
275
|
+
.gw-mobile-menu[data-gw-menu="drill"] { display: flex; flex-direction: column; overflow-x: hidden; padding-bottom: 0; }
|
|
276
|
+
.gw-mobile-menu[data-gw-menu="drill"] > nav { display: flex; flex: 1 0 auto; flex-direction: column; }
|
|
277
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__group > summary,
|
|
278
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__top { min-height: 64px; font-size: 24px; letter-spacing: -0.01em; }
|
|
279
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__group > summary::after { width: 20px; height: 20px; -webkit-mask-size: 20px; mask-size: 20px; }
|
|
280
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__list { padding: 0; }
|
|
281
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__list .gw-mobile-menu__link {
|
|
282
|
+
gap: 12px;
|
|
283
|
+
min-height: 56px;
|
|
284
|
+
padding-block: 12px;
|
|
285
|
+
border-bottom: 1px solid var(--gw-line);
|
|
286
|
+
color: var(--gw-ink);
|
|
287
|
+
font-weight: 500;
|
|
288
|
+
}
|
|
289
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__link b { display: block; font-size: 16px; font-weight: 500; line-height: 20px; }
|
|
290
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__link i { display: block; margin-top: 2px; color: var(--gw-muted); font-size: 13px; font-style: normal; line-height: 18px; }
|
|
291
|
+
/* a level's optional parts: a muted heading over a run of links, a 32px icon tile before a link's words */
|
|
292
|
+
.gw-mobile-menu__heading { padding: 16px 0 8px; color: var(--gw-muted); font-size: 12px; font-weight: 500; line-height: 1.5; }
|
|
293
|
+
.gw-mobile-menu__icon {
|
|
294
|
+
display: grid;
|
|
295
|
+
place-items: center;
|
|
296
|
+
flex: none;
|
|
297
|
+
width: 32px;
|
|
298
|
+
height: 32px;
|
|
299
|
+
border: 1px solid var(--gw-line);
|
|
300
|
+
border-radius: var(--gw-radius-action);
|
|
301
|
+
color: var(--gw-muted);
|
|
302
|
+
}
|
|
303
|
+
.gw-mobile-menu__icon svg { width: 16px; height: 16px; }
|
|
304
|
+
/* a level's last link, to the section's own page: its row opens the level instead of going there */
|
|
305
|
+
.gw-mobile-menu__all { display: flex; align-items: center; gap: 6px; min-height: 56px; color: var(--gw-accent-strong); font-size: 16px; font-weight: 500; }
|
|
306
|
+
.gw-mobile-menu__all::after { content: "\2192"; }
|
|
307
|
+
/* the action bar: what is not a button (Sign in, the language) on the first line, the last one on the right; the
|
|
308
|
+
buttons side by side under it (gw-btn--lg: 48px, as gemboss.ai) */
|
|
309
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__actions {
|
|
310
|
+
position: sticky;
|
|
311
|
+
bottom: 0;
|
|
312
|
+
z-index: 1;
|
|
313
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 8px 16px;
|
|
316
|
+
margin-top: auto;
|
|
317
|
+
padding-block: 12px calc(12px + env(safe-area-inset-bottom));
|
|
318
|
+
border-top: 1px solid var(--gw-line);
|
|
319
|
+
background: var(--gw-bg);
|
|
320
|
+
}
|
|
321
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__actions > .gw-btn { grid-row: 2; }
|
|
322
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__actions > :not(.gw-btn) { grid-row: 1; }
|
|
323
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__actions > :not(.gw-btn):last-child { grid-column: 2; justify-self: end; }
|
|
324
|
+
.gw-mobile-menu[data-gw-menu="drill"] .gw-mobile-menu__actions > .gw-mobile-menu__link { color: var(--gw-ink); font-weight: 500; }
|
|
325
|
+
|
|
326
|
+
/* Wired (.is-drill, set by website.js): a group's row points right and opens its level, which shows alone
|
|
327
|
+
(.is-drilled) under its head: Back on the left, the level's title in the middle. The sheet starts where the header
|
|
328
|
+
ends (--gw-menu-top, measured by the script) and the header stays in place, so an announcement bar above it does not
|
|
329
|
+
jump away. */
|
|
330
|
+
.gw-mobile-menu.is-drill { top: var(--gw-menu-top, calc(var(--gw-header-height) + 1px)); }
|
|
331
|
+
html.is-menu-open .gw-header:has(> .gw-mobile-menu.is-drill) { position: sticky; }
|
|
332
|
+
.gw-mobile-menu.is-drill .gw-mobile-menu__group > summary::after { transform: rotate(-90deg); transition: none; }
|
|
333
|
+
.gw-mobile-menu.is-drilled > nav > :not([open], .gw-mobile-menu__actions),
|
|
334
|
+
.gw-mobile-menu.is-drilled .gw-mobile-menu__group[open] > summary { display: none; }
|
|
335
|
+
.gw-mobile-menu__head { display: none; }
|
|
336
|
+
.gw-mobile-menu.is-drilled .gw-mobile-menu__group[open] > .gw-mobile-menu__head {
|
|
337
|
+
display: grid;
|
|
338
|
+
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
|
339
|
+
align-items: center;
|
|
340
|
+
gap: 8px;
|
|
341
|
+
min-height: 56px;
|
|
342
|
+
margin-bottom: 8px;
|
|
343
|
+
border-bottom: 1px solid var(--gw-line);
|
|
344
|
+
}
|
|
345
|
+
.gw-mobile-menu__back {
|
|
346
|
+
display: inline-flex;
|
|
347
|
+
align-items: center;
|
|
348
|
+
justify-self: start;
|
|
349
|
+
gap: 4px;
|
|
350
|
+
min-height: var(--gw-tap-min);
|
|
351
|
+
padding: 0 8px 0 0;
|
|
352
|
+
border: 0;
|
|
353
|
+
background: transparent;
|
|
354
|
+
color: var(--gw-muted);
|
|
355
|
+
font-size: 16px;
|
|
356
|
+
line-height: 1.5;
|
|
357
|
+
}
|
|
358
|
+
.gw-mobile-menu__back:hover { color: var(--gw-ink); }
|
|
359
|
+
.gw-mobile-menu__back::before {
|
|
360
|
+
content: "";
|
|
361
|
+
flex: none;
|
|
362
|
+
width: 16px;
|
|
363
|
+
height: 16px;
|
|
364
|
+
background: currentColor;
|
|
365
|
+
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M15 18l-6-6 6-6' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 16px no-repeat;
|
|
366
|
+
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M15 18l-6-6 6-6' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 16px no-repeat;
|
|
367
|
+
}
|
|
368
|
+
.gw-mobile-menu__title { color: var(--gw-ink); font-size: 16px; font-weight: 600; letter-spacing: 0; line-height: 1.5; text-align: center; }
|
|
369
|
+
/* a level slides in from the right, the top level back in from the left; under reduced motion they just switch */
|
|
370
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
371
|
+
.gw-mobile-menu.is-drilled .gw-mobile-menu__group[open] { animation: gw-drill-in 0.24s var(--gw-ease); }
|
|
372
|
+
.gw-mobile-menu.is-back > nav > :not(.gw-mobile-menu__actions) { animation: gw-drill-back 0.24s var(--gw-ease); }
|
|
373
|
+
}
|
|
374
|
+
@keyframes gw-drill-in { from { opacity: 0; transform: translateX(32px); } }
|
|
375
|
+
@keyframes gw-drill-back { from { opacity: 0; transform: translateX(-32px); } }
|
|
376
|
+
|
|
377
|
+
/* The language switcher: <div class="gw-lang" data-gw-lang hidden>, a ghost button with the globe and the current
|
|
378
|
+
language's short code, and its list of the other languages, a small popover card (the card radius of the site's
|
|
379
|
+
profile, one hairline, the raised shadow). website.js fills the list from the page's <link rel="alternate" hreflang>
|
|
380
|
+
and takes `hidden` away when there is another language. In the header's actions it shows from 1080px, where the nav
|
|
381
|
+
is; below that a second one sits in the mobile menu's action bar, and its list opens upward. */
|
|
382
|
+
.gw-lang { position: relative; flex: none; }
|
|
383
|
+
.gw-header__actions > .gw-lang { display: none; }
|
|
384
|
+
.gw-lang__btn {
|
|
385
|
+
display: inline-flex;
|
|
386
|
+
align-items: center;
|
|
387
|
+
gap: 6px;
|
|
388
|
+
height: 40px;
|
|
389
|
+
padding: 0 12px;
|
|
390
|
+
border: 0;
|
|
391
|
+
border-radius: var(--gw-radius-action);
|
|
392
|
+
background: transparent;
|
|
393
|
+
color: var(--gw-ink-2);
|
|
394
|
+
font-size: 14px;
|
|
395
|
+
font-weight: 500;
|
|
396
|
+
line-height: 1.4;
|
|
397
|
+
white-space: nowrap;
|
|
398
|
+
}
|
|
399
|
+
.gw-lang__btn:hover,
|
|
400
|
+
.gw-lang__btn[aria-expanded="true"] { background: var(--gw-bg-soft); color: var(--gw-ink); }
|
|
401
|
+
.gw-lang__btn svg { flex: none; width: 18px; height: 18px; }
|
|
402
|
+
.gw-lang__menu {
|
|
403
|
+
position: absolute;
|
|
404
|
+
top: calc(100% + 8px);
|
|
405
|
+
right: 0;
|
|
406
|
+
z-index: 1;
|
|
407
|
+
display: grid;
|
|
408
|
+
gap: 2px;
|
|
409
|
+
min-width: 200px;
|
|
410
|
+
margin: 0;
|
|
411
|
+
padding: 8px;
|
|
412
|
+
list-style: none;
|
|
413
|
+
border: 1px solid var(--gw-line);
|
|
414
|
+
border-radius: var(--gw-radius-card);
|
|
415
|
+
background: var(--gw-surface);
|
|
416
|
+
box-shadow: var(--gw-shadow-raised);
|
|
417
|
+
}
|
|
418
|
+
.gw-lang__link {
|
|
419
|
+
display: flex;
|
|
420
|
+
align-items: center;
|
|
421
|
+
min-height: 40px;
|
|
422
|
+
padding: 0 12px;
|
|
423
|
+
border-radius: var(--gw-radius-action);
|
|
424
|
+
color: var(--gw-ink);
|
|
425
|
+
font-size: 14px;
|
|
426
|
+
font-weight: 500;
|
|
427
|
+
white-space: nowrap;
|
|
428
|
+
}
|
|
429
|
+
.gw-lang__link:hover { background: var(--gw-bg-soft); }
|
|
430
|
+
.gw-mobile-menu .gw-lang__btn { padding: 0 8px 0 0; color: var(--gw-muted); font-size: 16px; }
|
|
431
|
+
.gw-mobile-menu .gw-lang__btn:hover,
|
|
432
|
+
.gw-mobile-menu .gw-lang__btn[aria-expanded="true"] { background: transparent; color: var(--gw-ink); }
|
|
433
|
+
.gw-mobile-menu .gw-lang__menu { top: auto; right: auto; bottom: calc(100% + 8px); left: 0; }
|
|
434
|
+
@media (max-width: 768px), (pointer: coarse) {
|
|
435
|
+
.gw-lang__btn, .gw-lang__link { min-height: var(--gw-tap-min); }
|
|
436
|
+
}
|
|
437
|
+
@media (min-width: 1080px) {
|
|
438
|
+
.gw-header__actions > .gw-lang { display: block; }
|
|
439
|
+
}
|