@gemboss/website-ui 0.0.0-stage → 0.2.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 +191 -0
- package/CHANGELOG.md +47 -0
- package/README.md +118 -2
- package/bin/gemboss-website.mjs +78 -0
- package/bin/lib/check.mjs +695 -0
- package/bin/lib/gap.mjs +189 -0
- package/bin/lib/init.mjs +99 -0
- package/bin/lib/upgrade.mjs +155 -0
- package/bin/lib/util.mjs +147 -0
- package/css/base.css +107 -0
- package/css/components.css +324 -0
- package/css/footer.css +53 -0
- package/css/header.css +231 -0
- package/css/layout.css +59 -0
- package/css/prose.css +119 -0
- package/css/sections.css +173 -0
- package/css/tokens.css +200 -0
- package/demo/index.html +703 -0
- package/demo/theme-example.html +133 -0
- package/docs/components.md +407 -0
- package/docs/markup-contract.md +148 -0
- package/docs/philosophy.md +69 -0
- package/docs/theming.md +96 -0
- package/js/website.js +186 -0
- package/package.json +50 -4
- package/skill/SKILL.md +49 -0
- package/system.json +122 -0
- package/templates/footer.html +40 -0
- package/templates/header.html +60 -0
- package/templates/page.html +134 -0
- package/templates/sections/cta.html +15 -0
- package/templates/sections/faq.html +24 -0
- package/templates/sections/features.html +32 -0
- package/templates/sections/hero.html +27 -0
- package/templates/sections/logos.html +15 -0
- package/templates/sections/pricing.html +51 -0
- package/templates/sections/prose.html +36 -0
- package/templates/sections/quote.html +13 -0
- package/templates/sections/showcase.html +25 -0
- package/templates/sections/split.html +27 -0
- package/templates/sections/stats.html +26 -0
- package/templates/sections/steps.html +17 -0
- package/website.css +1274 -0
- package/website.min.css +1 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Markup contract (CSS ↔ JS ↔ templates)
|
|
2
|
+
|
|
3
|
+
The full class list, including the CSS-only helpers added while building 0.1.0 (`.gw-h1`–`.gw-h4`, `.gw-lead`,
|
|
4
|
+
`.gw-note`, `.gw-actions`, `.gw-stats__source`, `.gw-plan__price`, the mobile menu parts…), is `docs/components.md`.
|
|
5
|
+
|
|
6
|
+
The CSS (`css/*.css`), the script (`js/website.js`) and the templates (`templates/`) agree on exactly this. Change it
|
|
7
|
+
here first, then in all three.
|
|
8
|
+
|
|
9
|
+
Prefixes: kit classes `gw-`, kit CSS variables `--gw-`, state classes `is-` (set by the script), hooks `data-gw-*`.
|
|
10
|
+
BEM-ish: `.gw-block`, `.gw-block__part`, `.gw-block--variant`.
|
|
11
|
+
|
|
12
|
+
## Page
|
|
13
|
+
|
|
14
|
+
```html
|
|
15
|
+
<html lang="en" data-theme?="light|dark"> <!-- absent = follow the system; the toggle sets it -->
|
|
16
|
+
<body class="gw-page">
|
|
17
|
+
<a class="gw-skip" href="#main">Skip to content</a>
|
|
18
|
+
<div class="gw-announce" role="region" aria-label="Announcement">…<a href="…">…</a></div> <!-- optional -->
|
|
19
|
+
<header class="gw-header" data-gw-header> … </header>
|
|
20
|
+
<main id="main"> sections </main>
|
|
21
|
+
<footer class="gw-footer"> … </footer>
|
|
22
|
+
<script src="…/website.js" defer></script>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Header
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<header class="gw-header" data-gw-header>
|
|
29
|
+
<div class="gw-wrap gw-header__bar">
|
|
30
|
+
<a class="gw-brand" href="/"><img class="gw-brand__mark" src="…" alt="" width="26" height="26"><span class="gw-brand__name">GemBoss</span></a>
|
|
31
|
+
<nav class="gw-nav" aria-label="Main">
|
|
32
|
+
<ul class="gw-nav__list">
|
|
33
|
+
<li class="gw-nav__item">
|
|
34
|
+
<button class="gw-nav__trigger" type="button" aria-expanded="false" aria-controls="gw-mega-product" data-gw-mega="gw-mega-product">Product</button>
|
|
35
|
+
<div class="gw-mega" id="gw-mega-product" hidden>
|
|
36
|
+
<div class="gw-wrap gw-mega__grid">
|
|
37
|
+
<div class="gw-mega__col"><p class="gw-mega__heading">Build</p>
|
|
38
|
+
<a class="gw-mega__link" href="…"><span class="gw-mega__icon" aria-hidden="true">svg</span><span><b>Title</b><i>One line</i></span></a>
|
|
39
|
+
</div>
|
|
40
|
+
<a class="gw-mega__feature" href="…">…</a> <!-- optional card on the right -->
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</li>
|
|
44
|
+
<li class="gw-nav__item"><a class="gw-nav__link" href="/pricing" aria-current?="page">Pricing</a></li>
|
|
45
|
+
</ul>
|
|
46
|
+
</nav>
|
|
47
|
+
<div class="gw-header__actions">
|
|
48
|
+
<button class="gw-theme-toggle" type="button" data-gw-theme-toggle aria-label="Switch to dark theme">svg</button>
|
|
49
|
+
<a class="gw-nav__link gw-header__signin" href="…">Sign in</a>
|
|
50
|
+
<a class="gw-btn gw-btn--secondary gw-btn--sm" href="…">Book a demo</a>
|
|
51
|
+
<a class="gw-btn gw-btn--sm" href="…">Start free</a>
|
|
52
|
+
<button class="gw-menu-toggle" type="button" aria-expanded="false" aria-controls="gw-mobile-menu" data-gw-menu-toggle aria-label="Open menu">svg</button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="gw-mobile-menu" id="gw-mobile-menu" hidden> …the same links as lists, and the actions… </div>
|
|
56
|
+
</header>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Script behaviour:
|
|
60
|
+
- **Mega menu**: click on a trigger toggles its panel (`hidden` removed, `aria-expanded="true"`, `.is-open` on the
|
|
61
|
+
`.gw-nav__item`). Hover opens on fine pointers after 80ms and closes 160ms after leaving item and panel. Only one
|
|
62
|
+
open at a time. `Escape` closes and returns focus to the trigger. A click outside closes. Arrow keys are not required.
|
|
63
|
+
- **Mobile menu** (below 1080px the `.gw-nav` and sign-in hide, `.gw-menu-toggle` shows): the toggle flips `hidden`
|
|
64
|
+
on `#gw-mobile-menu`, `aria-expanded`, `.is-menu-open` on `<html>` (the CSS locks scroll), and its `aria-label`
|
|
65
|
+
(Open menu / Close menu). `Escape` closes. Resizing above 1080px closes it.
|
|
66
|
+
- **Labels in other languages**: the script never writes English over a translated page. Give the toggles their
|
|
67
|
+
words with `data-gw-label-open` / `data-gw-label-close` (menu toggle) and `data-gw-label-dark` /
|
|
68
|
+
`data-gw-label-light` (theme toggle); without them it keeps the markup's own `aria-label`, then falls back to English.
|
|
69
|
+
- **Theme toggle**: cycles light ↔ dark by setting `data-theme` on `<html>`, stores the choice in `localStorage`
|
|
70
|
+
(`gw-theme`, every access in try/catch), updates `aria-label`. On load, a stored choice is applied before paint by a
|
|
71
|
+
one-line inline script in `<head>` that the template carries (`templates/page.html`).
|
|
72
|
+
- **Reveal**: elements with `.gw-reveal` get `.is-in` when 15% visible (IntersectionObserver, once). Failsafe: after
|
|
73
|
+
2.5s, or if IntersectionObserver is missing, all get `.is-in`. Under `prefers-reduced-motion` the CSS shows them
|
|
74
|
+
without motion.
|
|
75
|
+
- **Header**: `.is-scrolled` on `[data-gw-header]` once the page scrolled more than 8px (the CSS may use it for the
|
|
76
|
+
hairline only; the bar does not change height or colour).
|
|
77
|
+
- **Current page**: the script marks `aria-current="page"` on `.gw-nav__link` / `.gw-mega__link` whose `href`
|
|
78
|
+
pathname equals `location.pathname` (when the markup did not already).
|
|
79
|
+
|
|
80
|
+
## Segmented control (0.2.0)
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<div class="gw-segmented" role="radiogroup" aria-label="Billing period" data-gw-segmented="billing">
|
|
84
|
+
<button type="button" role="radio" aria-checked="true" value="monthly">Monthly</button>
|
|
85
|
+
<button type="button" role="radio" aria-checked="false" value="yearly">Yearly –20%</button>
|
|
86
|
+
</div>
|
|
87
|
+
…
|
|
88
|
+
<span data-gw-show="billing:monthly">$49</span><span data-gw-show="billing:yearly" hidden>$39</span>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- The group: `.gw-segmented` (+ `--block`), `role="radiogroup"`, a name (`aria-label` or `aria-labelledby`) and
|
|
92
|
+
`data-gw-segmented="<name>"`, unique on the page. Two to four `<button type="button" role="radio">` children, each
|
|
93
|
+
with a `value` and `aria-checked`, exactly one `"true"`: the choice the page shows without the script.
|
|
94
|
+
- Content: any element with `data-gw-show="<name>:<value>"` (several tokens, space-separated, for several options) is
|
|
95
|
+
shown only while that option is chosen. The markup sets `hidden` on every version but the default's.
|
|
96
|
+
- Script behaviour: on load the checked option (or the first) is chosen again, which corrects `hidden` on the content
|
|
97
|
+
and sets the roving `tabindex` (`0` on the chosen option, `-1` on the others: one Tab stop). A click chooses;
|
|
98
|
+
ArrowRight / ArrowDown and ArrowLeft / ArrowUp move to the next and previous option, wrapping, Home and End to the
|
|
99
|
+
first and last, and the moved-to option is chosen and focused (the radio-group pattern). Each choice made by the
|
|
100
|
+
reader fires `gw:segmented` on the group (`bubbles: true`, `detail: { name, value }`); loading does not.
|
|
101
|
+
|
|
102
|
+
## Busy button (0.2.0)
|
|
103
|
+
|
|
104
|
+
- Markup: `aria-busy="true"` on a `<button class="gw-btn">` while it waits; the page's own script sets and removes it.
|
|
105
|
+
No `disabled` at the same time, no loading class, no spinner element: the CSS draws the ring (`::before`; `--sm`
|
|
106
|
+
keeps `::after` for its hit area).
|
|
107
|
+
- Script behaviour: one click listener on the document, in the capture phase, cancels a click on a
|
|
108
|
+
`.gw-btn[aria-busy="true"]` (or inside one) before the page's handlers see it: `preventDefault` and
|
|
109
|
+
`stopPropagation`. A form is not submitted twice, by mouse, Enter or Space, and the button keeps the focus.
|
|
110
|
+
|
|
111
|
+
## Components (CSS only)
|
|
112
|
+
|
|
113
|
+
| class | what |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `.gw-btn` (+ `--secondary`, `--ghost`, `--on-night`, `--sm`, `--lg`, `--block`) | actions; rectangle 8px, ink fill by default; `:active` pressed, `[aria-busy="true"]` loading (script above) |
|
|
116
|
+
| `.gw-link` (+ `--arrow`) | inline text link in the accent; `--arrow` adds → that moves 2px on hover |
|
|
117
|
+
| `.gw-label` | the label above a heading: 13px muted, 8px accent square before it |
|
|
118
|
+
| `.gw-dim` | the muted first sentence inside a headline |
|
|
119
|
+
| `.gw-chip` (+ `.is-active`) | selectable token, pill |
|
|
120
|
+
| `.gw-segmented` (+ `--block`) | one choice out of two to four (script above) |
|
|
121
|
+
| `.gw-badge` (+ `--accent`, `--success`, `--warning`) | small status, pill |
|
|
122
|
+
| `.gw-card` (+ `--soft`, `--link`) | white box, 1px hairline, radius 12; `--link` makes the whole card an `<a>` |
|
|
123
|
+
| `.gw-window` (+ `__bar`, `__body`, `__caption`) | a framed screenshot/mock-up, like a browser or app window; `__caption` (last child) says what its numbers are: "Illustrative data." or their source |
|
|
124
|
+
| `.gw-icon` | 20px inline stroke SVG wrapper |
|
|
125
|
+
| `.gw-list--check` | list with check marks |
|
|
126
|
+
| `.gw-table` (+ `--compare`) | data / comparison table; scrolls inside its box on phones |
|
|
127
|
+
| `.gw-faq` (`<details class="gw-faq__item"><summary>`) | FAQ accordion with no script |
|
|
128
|
+
| `.gw-prose` (+ `--sm`) | long-form reading: an article's own HTML, no classes inside |
|
|
129
|
+
| `.gw-tabs` (`role="tablist"`) | NOT in the kit yet (a reported gap would add it) |
|
|
130
|
+
|
|
131
|
+
## Sections
|
|
132
|
+
|
|
133
|
+
| class | what |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `.gw-section` (+ `--soft`, `--tight`) | vertical rhythm; `--soft` on `--gw-bg-soft` |
|
|
136
|
+
| `.gw-band--night` | a dark band (max three per page plus the closing CTA, never adjacent) |
|
|
137
|
+
| `.gw-section__head` (+ `--center`) | label + h2 + one or two lines, max 660px |
|
|
138
|
+
| `.gw-hero` (+ `__wash`, `--center`, `--compact`) | first screen; `__wash` is the soft colour wash behind it |
|
|
139
|
+
| `.gw-logos` | proof strip of customer or partner logos (real ones only) |
|
|
140
|
+
| `.gw-stats` (`__item`, `__value`, `__label`) | 3–4 facts in a row, each with its source |
|
|
141
|
+
| `.gw-features` (`.gw-grid--3` of `.gw-feature`) | icon, title, one or two lines |
|
|
142
|
+
| `.gw-split` (+ `--reverse`) | text beside a figure; stacks on phones |
|
|
143
|
+
| `.gw-showcase` | night band with a large screen and two or three points |
|
|
144
|
+
| `.gw-steps` (`<ol>`) | numbered steps, how it works |
|
|
145
|
+
| `.gw-quote` | one real testimonial with name, role, company |
|
|
146
|
+
| `.gw-pricing` (`.gw-plan`, `--featured`) | plan cards; the featured one has a hairline in the accent, not a fill |
|
|
147
|
+
| `.gw-cta` | closing call to action band |
|
|
148
|
+
| `.gw-footer` (`__grid`, `__col`, `__heading`, `__bottom`) | footer columns + bottom row |
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Why the kit looks the way it does
|
|
2
|
+
|
|
3
|
+
`AGENTS.md` says what to do. This page says why, so that when a case is not covered you can decide in the same
|
|
4
|
+
spirit. Each rule came from measuring real pages, not from taste alone.
|
|
5
|
+
|
|
6
|
+
## One language for the site and the product
|
|
7
|
+
|
|
8
|
+
A visitor crosses from the website into the product in one click. If the two disagree (a cream site and a white app,
|
|
9
|
+
a purple button outside and a black one inside), the seam is exactly where trust is decided. So the website uses the
|
|
10
|
+
product's language: white canvas, ink actions, one accent, hairlines, almost no shadow.
|
|
11
|
+
|
|
12
|
+
## The thing you click is ink
|
|
13
|
+
|
|
14
|
+
A page has one thing it wants you to do. Near-black is the strongest mark on white and stays readable in every
|
|
15
|
+
light; the accent colour is louder but weaker as an instruction, and a page full of accent-coloured buttons has no
|
|
16
|
+
hierarchy left. The accent marks where you are and what is interactive in text: links, focus, the active item, the
|
|
17
|
+
small square before a label.
|
|
18
|
+
|
|
19
|
+
## Shapes carry meaning
|
|
20
|
+
|
|
21
|
+
Rectangles act, pills choose. When every button is a rectangle and every chip is a pill, people stop reading the
|
|
22
|
+
shape and read the words. A radius scale with seven steps means two cards side by side never differ by 2px, which is
|
|
23
|
+
the kind of drift that makes a page feel assembled rather than designed.
|
|
24
|
+
|
|
25
|
+
## Separation by line and space, light only at rest
|
|
26
|
+
|
|
27
|
+
A shadow says "this floats above". Cards on a page do not float; they sit on it. A 1px hairline and the dashboard's
|
|
28
|
+
resting shadow (two faint pixels) separate a card and keep the page calm; nothing lifts on hover. Larger light is
|
|
29
|
+
spent where it means something: a popover such as the mega menu, an illustration that lights itself.
|
|
30
|
+
|
|
31
|
+
## Scales instead of eyeballing
|
|
32
|
+
|
|
33
|
+
On gemboss.ai, before the scales, the four most common paddings were 9, 5, 12 and 15 pixels: spacing tuned by eye,
|
|
34
|
+
one element at a time. 1,291 of 2,076 spacing values belonged to no scale. Nobody sees one 15px; everybody feels a
|
|
35
|
+
page made of hundreds of them. The scales (4px base, the type steps) make "a bit more space" a choice between two
|
|
36
|
+
real values.
|
|
37
|
+
|
|
38
|
+
## Sentence case and a quiet label
|
|
39
|
+
|
|
40
|
+
Uppercase tracked labels ("FEATURES") shout and say nothing. A label in sentence case, muted, led by a small accent
|
|
41
|
+
square, tells the reader where they are and lets the headline do the talking. The house headline shape, a muted
|
|
42
|
+
first sentence and an ink second one, gives every section the same rhythm: context, then the point.
|
|
43
|
+
|
|
44
|
+
## One idea per section
|
|
45
|
+
|
|
46
|
+
Visitors scan. A section that makes one point, with one action, survives scanning; a section with three ideas loses
|
|
47
|
+
all three. Fewer, clearer sections beat a long page of everything.
|
|
48
|
+
|
|
49
|
+
## Real words, or a visible gap
|
|
50
|
+
|
|
51
|
+
A made-up number or a fake testimonial is found out, and then every other claim on the page is doubted. A visible
|
|
52
|
+
`[PRICE]` is honest and gets filled; an invented one ships.
|
|
53
|
+
|
|
54
|
+
## Motion that does not cost
|
|
55
|
+
|
|
56
|
+
Only `transform` and `opacity` move, because they do not re-layout the page and stay at 60 fps on a cheap phone.
|
|
57
|
+
Everything stops for people who ask the system for reduced motion. Motion explains (a panel opens from its
|
|
58
|
+
trigger, a section arrives as you reach it); it never decorates.
|
|
59
|
+
|
|
60
|
+
## Phones first in the checking, not just in the CSS
|
|
61
|
+
|
|
62
|
+
Most visitors from ads arrive on a phone. A label that fits at 1366px and overflows at 320px is a broken page for
|
|
63
|
+
most of them. That is why the kit is checked at 320, 390, 768 and 1366px, and why translated labels must not grow.
|
|
64
|
+
|
|
65
|
+
## When the kit is not enough
|
|
66
|
+
|
|
67
|
+
A kit that people work around stops being a kit. The workaround is allowed, in the site, with the tokens, under a
|
|
68
|
+
`kitgap-` name, and it is reported. The next version of the kit gets the real thing, every site gets it, and the
|
|
69
|
+
workaround is deleted. That loop, not the first release, is what keeps all GemCommerce websites one family.
|
package/docs/theming.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Theming for a brand other than GemBoss
|
|
2
|
+
|
|
3
|
+
A GemCommerce brand (GemPages, Gemians…) uses the kit as it is and changes four things: **its accent colour**, **the
|
|
4
|
+
wash hues**, **its logo** and **its favicon**. Everything else stays the kit's: the shapes, the scales, the ink
|
|
5
|
+
buttons, the white canvas, the type. That is what makes the sites look like one family, and it is why a site built
|
|
6
|
+
by someone with no design training still looks careful.
|
|
7
|
+
|
|
8
|
+
Working example: `demo/theme-example.html` (the demo's hero, buttons and cards with a GemPages-style blue).
|
|
9
|
+
|
|
10
|
+
## What you may override
|
|
11
|
+
|
|
12
|
+
Put these in the site's own CSS (`site.css`, linked **after** `website.css`), in **one `:root {}` block**, and nowhere
|
|
13
|
+
else.
|
|
14
|
+
|
|
15
|
+
| variable | what it is | GemBoss value |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `--gw-accent-light` | the accent on light pages: links, focus ring, active item, label square, chart line | `#5661f5` |
|
|
18
|
+
| `--gw-accent-strong-light` | the accent on hover and as text on `--gw-accent-soft` | `#3a44de` |
|
|
19
|
+
| `--gw-accent-soft-light` | the accent's tint: active chip, chosen segmented option, accent badge, icon hover | `#eef0fe` |
|
|
20
|
+
| `--gw-on-accent-light` | text on a solid accent (rare; the kit never fills a button with it) | `#ffffff` |
|
|
21
|
+
| `--gw-accent-dark` | the accent in dark mode **and inside night bands** | `#7c84ff` |
|
|
22
|
+
| `--gw-accent-strong-dark` | its hover / strong form | `#a3a9ff` |
|
|
23
|
+
| `--gw-accent-soft-dark` | its tint | `#1f2140` |
|
|
24
|
+
| `--gw-on-accent-dark` | text on it | `#0d0d12` |
|
|
25
|
+
| `--gw-wash-pink`, `--gw-wash-violet`, `--gw-wash-sky`, `--gw-wash-mint` | the secondary hues of the hero wash and illustrations | `#ff6f5e`, `#7c8af7`, `#43b0ff`, `#1fcf9f` |
|
|
26
|
+
|
|
27
|
+
Why the `-light` / `-dark` names and not `--gw-accent` itself: the kit sets `--gw-accent` from these in four places
|
|
28
|
+
(the light default, dark from the system setting, dark from the toggle, light from the toggle) and in every night
|
|
29
|
+
band. Override the eight primitives once and all of them follow. Overriding `--gw-accent` directly works only until
|
|
30
|
+
someone presses the theme toggle.
|
|
31
|
+
|
|
32
|
+
The logo and the favicon are files, not variables: the `<img class="gw-brand__mark">` (26×26, SVG preferred, with
|
|
33
|
+
`alt=""` because the name is written next to it) and the `<span class="gw-brand__name">` in the header and footer
|
|
34
|
+
partials, and `<link rel="icon">` in the page head.
|
|
35
|
+
|
|
36
|
+
## What you may not change
|
|
37
|
+
|
|
38
|
+
- **Neutrals**: `--gw-bg`, `--gw-bg-soft`, `--gw-surface`, `--gw-ink`, `--gw-ink-2`, `--gw-muted`, `--gw-line`,
|
|
39
|
+
`--gw-line-strong`, and the night colours. No cream or grey page, no brand-coloured ink.
|
|
40
|
+
- **The action**: `--gw-action`, `--gw-action-hover`, `--gw-on-action`. Buttons stay ink. Your accent never fills a
|
|
41
|
+
button, a section or a header.
|
|
42
|
+
- **State**: `--gw-success`, `--gw-warning` and their tints. Green and amber mean state on every GemCommerce site.
|
|
43
|
+
- **Scales and shapes**: type sizes and weights, spacing, radii, shadows, widths, the header height, the
|
|
44
|
+
font family. No rounder buttons, no bigger shadows, no other font.
|
|
45
|
+
- **Kit classes**: never restyle a `.gw-*` class. If the kit cannot do what your brand needs, that is a gap
|
|
46
|
+
(`AGENTS.md` section 9), not a theme.
|
|
47
|
+
|
|
48
|
+
`npx gemboss-website check` reports colours outside the tokens, overrides of `.gw-*` classes and accent-filled
|
|
49
|
+
buttons.
|
|
50
|
+
|
|
51
|
+
## A full example
|
|
52
|
+
|
|
53
|
+
```css
|
|
54
|
+
/* site.css, linked after website.css. The whole theme of a GemPages-style site. */
|
|
55
|
+
:root {
|
|
56
|
+
/* accent, light pages: #1e5eff on #ffffff is 5.1:1 */
|
|
57
|
+
--gw-accent-light: #1e5eff;
|
|
58
|
+
--gw-accent-strong-light: #1547d1;
|
|
59
|
+
--gw-accent-soft-light: #eaf0ff;
|
|
60
|
+
--gw-on-accent-light: #ffffff;
|
|
61
|
+
/* accent, dark mode and night bands: #6b98ff on #0d0d12 is 7.0:1 */
|
|
62
|
+
--gw-accent-dark: #6b98ff;
|
|
63
|
+
--gw-accent-strong-dark: #a8c0ff;
|
|
64
|
+
--gw-accent-soft-dark: #16213d;
|
|
65
|
+
--gw-on-accent-dark: #0d0d12;
|
|
66
|
+
/* wash: only if the brand has its own secondary hues; otherwise leave the kit's */
|
|
67
|
+
--gw-wash-violet: #7b9cff;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```html
|
|
72
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
73
|
+
…
|
|
74
|
+
<a class="gw-brand" href="/"><img class="gw-brand__mark" src="/logo-mark.svg" alt="" width="26" height="26"><span class="gw-brand__name">GemPages</span></a>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Check the contrast before you ship
|
|
78
|
+
|
|
79
|
+
The accent family is read as text: the kit draws links, step numbers, active menu items and badge words in
|
|
80
|
+
`--gw-accent-strong`, and the label square, focus ring, check marks and the featured plan's hairline in `--gw-accent`.
|
|
81
|
+
So:
|
|
82
|
+
|
|
83
|
+
1. **Accent text on white at least 4.5:1**: `--gw-accent-light` and `--gw-accent-strong-light` on `#ffffff`.
|
|
84
|
+
Check `--gw-accent-strong-light` on `--gw-bg-soft` (`#f2f3f5`) too, where links sit in soft sections.
|
|
85
|
+
2. `--gw-accent-strong-light` on `--gw-accent-soft-light`: at least 4.5:1 (the accent badge, the active chip and the
|
|
86
|
+
chosen option of a segmented control).
|
|
87
|
+
3. `--gw-accent-dark` and `--gw-accent-strong-dark` on `#0d0d12` (dark page) and on `#0c0e14` (night band): at
|
|
88
|
+
least 4.5:1.
|
|
89
|
+
4. `--gw-accent-strong-dark` on `--gw-accent-soft-dark`: at least 4.5:1.
|
|
90
|
+
|
|
91
|
+
Any WCAG contrast checker works (paste the two hex values). A bright brand colour (yellow, light green, cyan) almost
|
|
92
|
+
never passes on white: darken it for `-light` and keep the bright one for `-dark`. If you cannot get 4.5:1 without
|
|
93
|
+
changing the brand colour beyond recognition, keep the kit's accent and use the brand colour only in the logo and
|
|
94
|
+
illustrations.
|
|
95
|
+
|
|
96
|
+
Then open the site in light and dark (the toggle in the header), at 320px and at 1366px, and look at a night band.
|
package/js/website.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/*! @gemboss/website-ui website.js | spec: docs/markup-contract.md | load with defer */
|
|
2
|
+
(function () {
|
|
3
|
+
'use strict';
|
|
4
|
+
if (window.gwWebsite) return;
|
|
5
|
+
window.gwWebsite = { version: '0.2.0' };
|
|
6
|
+
var doc = document, root = doc.documentElement;
|
|
7
|
+
function mq(q) { return window.matchMedia ? matchMedia(q) : { matches: false }; }
|
|
8
|
+
function watch(m, fn) { if (m.addEventListener) m.addEventListener('change', fn); }
|
|
9
|
+
function all(sel, ctx) { return [].slice.call((ctx || doc).querySelectorAll(sel)); }
|
|
10
|
+
function on(el, type, fn, opt) { el.addEventListener(type, fn, opt || false); }
|
|
11
|
+
function attr(el, name, dflt) { return el.getAttribute(name) || dflt; }
|
|
12
|
+
function esc(e) { return e.key === 'Escape'; }
|
|
13
|
+
function link(e) { return e.target.closest('a[href]'); }
|
|
14
|
+
|
|
15
|
+
// Mega menu. Opened by hover then clicked, a panel stays open: the click meant "open".
|
|
16
|
+
function mega() {
|
|
17
|
+
var fine = mq('(hover: hover) and (pointer: fine)'), open = null, timer = 0;
|
|
18
|
+
var menus = all('[data-gw-mega]').map(function (t) {
|
|
19
|
+
var p = doc.getElementById(attr(t, 'data-gw-mega', attr(t, 'aria-controls', '')));
|
|
20
|
+
return p && { t: t, p: p, item: t.closest('.gw-nav__item') || t.parentNode };
|
|
21
|
+
}).filter(Boolean);
|
|
22
|
+
if (!menus.length) return;
|
|
23
|
+
function set(m, show, mode) {
|
|
24
|
+
m.p.hidden = !show;
|
|
25
|
+
m.t.setAttribute('aria-expanded', String(show));
|
|
26
|
+
m.item.classList.toggle('is-open', show);
|
|
27
|
+
m.mode = mode;
|
|
28
|
+
open = show ? m : open === m ? null : open;
|
|
29
|
+
}
|
|
30
|
+
function show(m, mode) {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
if (open === m) { if (mode === 'click') m.mode = mode; return; }
|
|
33
|
+
if (open) set(open, false);
|
|
34
|
+
set(m, true, mode);
|
|
35
|
+
}
|
|
36
|
+
function hide(focus) {
|
|
37
|
+
var m = open;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
if (!m) return;
|
|
40
|
+
set(m, false);
|
|
41
|
+
if (focus === true) m.t.focus();
|
|
42
|
+
}
|
|
43
|
+
function inside(m, el) { return !!el && (m.item.contains(el) || m.p.contains(el)); }
|
|
44
|
+
menus.forEach(function (m) {
|
|
45
|
+
on(m.t, 'click', function () { if (open === m && m.mode !== 'hover') hide(); else show(m, 'click'); });
|
|
46
|
+
(m.item.contains(m.p) ? [m.item] : [m.item, m.p]).forEach(function (el) {
|
|
47
|
+
on(el, 'mouseenter', function () {
|
|
48
|
+
if (fine.matches) { clearTimeout(timer); if (open !== m) timer = setTimeout(function () { show(m, 'hover'); }, 80); }
|
|
49
|
+
});
|
|
50
|
+
on(el, 'mouseleave', function () {
|
|
51
|
+
if (fine.matches) { clearTimeout(timer); if (open === m && m.mode === 'hover') timer = setTimeout(hide, 160); }
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
on(m.item, 'focusout', function (e) { if (open === m && e.relatedTarget && !inside(m, e.relatedTarget)) hide(); });
|
|
55
|
+
on(m.p, 'click', function (e) { if (link(e)) hide(); });
|
|
56
|
+
});
|
|
57
|
+
on(doc, 'click', function (e) { if (open && !inside(open, e.target)) hide(); });
|
|
58
|
+
on(doc, 'keydown', function (e) { if (open && esc(e)) hide(inside(open, doc.activeElement) || open.mode === 'click'); });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Mobile menu. Labels: data-gw-label-open/-close, else the markup's aria-label, else English.
|
|
62
|
+
function mobile() {
|
|
63
|
+
var btn = doc.querySelector('[data-gw-menu-toggle]');
|
|
64
|
+
var menu = btn && doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
|
|
65
|
+
if (!menu) return;
|
|
66
|
+
var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
|
|
67
|
+
var wide = mq('(min-width: 1080px)');
|
|
68
|
+
function isOpen() { return !menu.hidden; }
|
|
69
|
+
function set(o, focus) {
|
|
70
|
+
menu.hidden = !o;
|
|
71
|
+
btn.setAttribute('aria-expanded', String(o));
|
|
72
|
+
btn.setAttribute('aria-label', label[+o]);
|
|
73
|
+
root.classList.toggle('is-menu-open', o);
|
|
74
|
+
if (focus) btn.focus();
|
|
75
|
+
}
|
|
76
|
+
on(btn, 'click', function () { set(!isOpen()); });
|
|
77
|
+
on(doc, 'keydown', function (e) { if (esc(e) && isOpen()) set(false, true); });
|
|
78
|
+
on(menu, 'click', function (e) { if (link(e)) set(false); }); // same-page anchors do not reload
|
|
79
|
+
watch(wide, function () { if (wide.matches && isOpen()) set(false); });
|
|
80
|
+
on(window, 'pageshow', function (e) { if (e.persisted && isOpen()) set(false); });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Theme toggle. Labels: data-gw-label-dark/-light, else the markup's aria-label, else English.
|
|
84
|
+
function theme() {
|
|
85
|
+
var sys = mq('(prefers-color-scheme: dark)'), stored = null;
|
|
86
|
+
try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
|
|
87
|
+
if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
|
|
88
|
+
var btns = all('[data-gw-theme-toggle]'), labels = btns.map(function (b) {
|
|
89
|
+
return [attr(b, 'data-gw-label-dark', attr(b, 'aria-label', 'Switch to dark theme')), attr(b, 'data-gw-label-light', 'Switch to light theme')];
|
|
90
|
+
});
|
|
91
|
+
function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
|
|
92
|
+
function relabel() { btns.forEach(function (b, i) { b.setAttribute('aria-label', labels[i][+dark()]); }); }
|
|
93
|
+
btns.forEach(function (b) {
|
|
94
|
+
on(b, 'click', function () {
|
|
95
|
+
var next = dark() ? 'light' : 'dark';
|
|
96
|
+
root.setAttribute('data-theme', next);
|
|
97
|
+
try { localStorage.setItem('gw-theme', next); } catch (e) { /* blocked */ }
|
|
98
|
+
relabel();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
watch(sys, relabel);
|
|
102
|
+
relabel();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Reveal at 15% visible, once. Failsafe: all shown after 2.5s, or at once without IntersectionObserver.
|
|
106
|
+
function reveal() {
|
|
107
|
+
var els = all('.gw-reveal'), io = null;
|
|
108
|
+
function showAll() { els.forEach(function (el) { el.classList.add('is-in'); }); if (io) io.disconnect(); }
|
|
109
|
+
if (!els.length) return;
|
|
110
|
+
if (!window.IntersectionObserver || mq('(prefers-reduced-motion: reduce)').matches) return showAll();
|
|
111
|
+
io = new IntersectionObserver(function (list) {
|
|
112
|
+
list.forEach(function (en) { if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } });
|
|
113
|
+
}, { threshold: 0.15 });
|
|
114
|
+
els.forEach(function (el) { io.observe(el); });
|
|
115
|
+
setTimeout(showAll, 2500);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Header: .is-scrolled past 8px, read once per frame.
|
|
119
|
+
function header() {
|
|
120
|
+
var h = doc.querySelector('[data-gw-header]'), state = false, queued = false;
|
|
121
|
+
if (!h) return;
|
|
122
|
+
function update() {
|
|
123
|
+
var s = window.scrollY > 8;
|
|
124
|
+
queued = false;
|
|
125
|
+
if (s !== state) h.classList.toggle('is-scrolled', (state = s));
|
|
126
|
+
}
|
|
127
|
+
on(window, 'scroll', function () { if (!queued) { queued = true; requestAnimationFrame(update); } }, { passive: true });
|
|
128
|
+
update();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// aria-current="page" on nav links to this path, unless the markup marked one.
|
|
132
|
+
function current() {
|
|
133
|
+
var menu = doc.getElementById('gw-mobile-menu');
|
|
134
|
+
var links = all('.gw-nav__link[href], .gw-mega__link[href]').concat(menu ? all('a[href]', menu) : []);
|
|
135
|
+
function norm(p) { return p.replace(/\/index\.html?$/, '/').replace(/\.html?$/, '').replace(/\/+$/, '') || '/'; }
|
|
136
|
+
if (links.some(function (a) { return a.hasAttribute('aria-current'); })) return;
|
|
137
|
+
links.forEach(function (a) {
|
|
138
|
+
if (a.classList.contains('gw-btn') || a.hash || a.host !== location.host) return;
|
|
139
|
+
if (norm(a.pathname) === norm(location.pathname)) a.setAttribute('aria-current', 'page');
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Segmented control: a radio group, one Tab stop; arrows (Home, End) move and choose. What one option shows carries
|
|
144
|
+
// data-gw-show="group:value" and is hidden unless that option is chosen; without the script the markup's own choice shows.
|
|
145
|
+
function segmented() {
|
|
146
|
+
all('[data-gw-segmented]').forEach(function (g) {
|
|
147
|
+
var name = attr(g, 'data-gw-segmented', ''), opts = all('[role="radio"]', g);
|
|
148
|
+
if (!opts.length) return;
|
|
149
|
+
function pick(o, focus, said) {
|
|
150
|
+
var v = attr(o, 'value', '');
|
|
151
|
+
opts.forEach(function (x) { x.setAttribute('aria-checked', String(x === o)); x.setAttribute('tabindex', x === o ? '0' : '-1'); });
|
|
152
|
+
if (focus) o.focus();
|
|
153
|
+
all('[data-gw-show]').forEach(function (el) {
|
|
154
|
+
var mine = attr(el, 'data-gw-show', '').split(/\s+/).filter(function (t) { return t.split(':')[0] === name; });
|
|
155
|
+
if (mine.length) el.hidden = mine.indexOf(name + ':' + v) < 0;
|
|
156
|
+
});
|
|
157
|
+
if (said && window.CustomEvent) g.dispatchEvent(new CustomEvent('gw:segmented', { bubbles: true, detail: { name: name, value: v } }));
|
|
158
|
+
}
|
|
159
|
+
opts.forEach(function (o, i) {
|
|
160
|
+
on(o, 'click', function () { pick(o, false, true); });
|
|
161
|
+
on(o, 'keydown', function (e) {
|
|
162
|
+
var n = opts.length, k = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1 + n, ArrowUp: i - 1 + n, Home: 0, End: n - 1 }[e.key];
|
|
163
|
+
if (k === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
pick(opts[k % n], true, true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
pick(opts.filter(function (o) { return attr(o, 'aria-checked', '') === 'true'; })[0] || opts[0]);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A busy button (aria-busy="true") ignores presses, by mouse or keyboard: no double submit. It keeps its focus and name.
|
|
173
|
+
function busy() {
|
|
174
|
+
on(doc, 'click', function (e) {
|
|
175
|
+
var b = e.target.closest && e.target.closest('.gw-btn[aria-busy="true"]');
|
|
176
|
+
if (b) { e.preventDefault(); e.stopPropagation(); }
|
|
177
|
+
}, true);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function init() {
|
|
181
|
+
[mega, mobile, theme, reveal, header, current, segmented, busy].forEach(function (f) {
|
|
182
|
+
try { f(); } catch (e) { console.error('[gw-website]', e); }
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (doc.readyState === 'loading') on(doc, 'DOMContentLoaded', init); else init();
|
|
186
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemboss/website-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The GemBoss website kit: tokens, header, footer, sections and the rules (AGENTS.md) that GemCommerce public websites share, plus a CLI that checks a site, reports kit gaps and upgrades.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"website.css",
|
|
9
|
+
"website.min.css",
|
|
10
|
+
"css/",
|
|
11
|
+
"js/",
|
|
12
|
+
"system.json",
|
|
13
|
+
"AGENTS.md",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"README.md",
|
|
16
|
+
"templates/",
|
|
17
|
+
"demo/",
|
|
18
|
+
"docs/",
|
|
19
|
+
"bin/",
|
|
20
|
+
"skill/"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
"./website.css": "./website.css",
|
|
24
|
+
"./website.min.css": "./website.min.css",
|
|
25
|
+
"./website.js": "./js/website.js",
|
|
26
|
+
"./system.json": "./system.json",
|
|
27
|
+
"./css/*": "./css/*",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"gemboss-website": "bin/gemboss-website.mjs"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "node scripts/build.mjs",
|
|
35
|
+
"test": "node test/run.mjs",
|
|
36
|
+
"prepack": "node scripts/build.mjs --check && node test/run.mjs"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/chriskoh26/gemboss-www.git",
|
|
44
|
+
"directory": "packages/website-ui"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"gemboss": {
|
|
50
|
+
"gapRepo": "chriskoh26/gemboss-www"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gemboss-website-ui
|
|
3
|
+
description: Use when building or changing pages of a website that uses @gemboss/website-ui (the GemCommerce website kit) - a new page, adding or changing a section, header, footer or copy, theming the kit for a brand, or reviewing a page before hand-over. Covers the kit's rules, its templates, the check, reporting a missing kit part (gap) and upgrading the kit.
|
|
4
|
+
---
|
|
5
|
+
<!-- managed by @gemboss/website-ui init -->
|
|
6
|
+
|
|
7
|
+
# Website pages with @gemboss/website-ui
|
|
8
|
+
|
|
9
|
+
The kit lives in `node_modules/@gemboss/website-ui/` (below: `KIT/`). The person asking is often not a designer;
|
|
10
|
+
the kit and its rules are how the page ends up looking designed. Compose from the kit, do not invent.
|
|
11
|
+
|
|
12
|
+
## Every time
|
|
13
|
+
|
|
14
|
+
1. **Read `KIT/AGENTS.md`** completely before the first page change of the session. It wins over habit and over
|
|
15
|
+
generic design advice.
|
|
16
|
+
2. **See what exists**: `KIT/demo/index.html` (every component) and `KIT/docs/components.md` (every class).
|
|
17
|
+
3. **Build**:
|
|
18
|
+
- New page: copy `KIT/templates/page.html`, then add sections from `KIT/templates/sections/` in the order the page
|
|
19
|
+
needs. Fill the words, delete what you did not use.
|
|
20
|
+
- Changed page: keep its kit classes; change words, order and choice of sections.
|
|
21
|
+
- Site CSS only for what the kit cannot know (an illustration, a product mock-up), with the kit's tokens and
|
|
22
|
+
scales, classes in the site's own prefix. Never style a `.gw-*` class, never edit `node_modules`.
|
|
23
|
+
- Another brand: change only the accent family, wash hues, logo and favicon (`KIT/docs/theming.md`).
|
|
24
|
+
4. **Words**: real facts only. A missing price, number, name or date becomes a visible `[PLACEHOLDER]` and a
|
|
25
|
+
question to the person. Made-up numbers in a drawn mock-up get `<p class="gw-window__caption">Illustrative
|
|
26
|
+
data.</p>` in its window. No em dash in visible text, no "Learn more" as a whole link.
|
|
27
|
+
5. **Check**: `npx gemboss-website check` and fix every error (`--fix-list` gives a to-do list). Then look at the
|
|
28
|
+
page at 320px and 1366px. Only then say it is done.
|
|
29
|
+
|
|
30
|
+
## When the kit is missing something
|
|
31
|
+
|
|
32
|
+
1. Make sure it is really missing (demo, components doc, two existing parts together, a variant).
|
|
33
|
+
2. A one-off illustration is a page thing: site CSS with tokens, stop.
|
|
34
|
+
3. A reusable thing is a gap: build a stopgap in `kit-gaps.css` (classes `kitgap-…`, tokens only, a comment naming
|
|
35
|
+
the gap), then report it:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npx gemboss-website gap --title "…" --kind component --need "…" --why "…" \
|
|
39
|
+
--page https://… --stopgap kit-gaps.css
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It files a GitHub issue (gh) or writes `kit-gaps/<slug>.md` to send. Put the printed `/* gap #N */` comment
|
|
43
|
+
above the stopgap. Tell the person what you reported.
|
|
44
|
+
|
|
45
|
+
## Upgrading the kit
|
|
46
|
+
|
|
47
|
+
`npx gemboss-website upgrade` shows what changed, the gaps the new version closes and the install command. Upgrade,
|
|
48
|
+
replace the listed `kitgap-` stopgaps with the kit's classes, run `npx gemboss-website init` (refreshes this skill)
|
|
49
|
+
and the check, and look at the pages again.
|