mosaic-headless 1.29.2 → 1.30.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 +17 -5
- package/SKILL.md +41 -2
- package/assets/templates/platforms/claude-ai.json +1 -1
- package/assets/templates/platforms/claude-code.json +1 -1
- package/assets/templates/platforms/codex-cli.json +1 -1
- package/assets/templates/platforms/copilot.json +1 -1
- package/assets/templates/platforms/gemini-cli.json +1 -1
- package/package.json +9 -2
- package/references/responsive.md +1 -0
- package/scripts/check-release.mjs +5 -1
- package/scripts/sync-version.mjs +3 -2
- package/sites/_components.py +560 -0
- package/sites/_content.py +341 -0
- package/sites/_conversion.py +225 -0
- package/sites/_dialogs.py +579 -0
- package/sites/_dynamic.py +311 -0
- package/sites/_forms.py +487 -0
- package/sites/_sliders.py +437 -0
- package/sites/_tokens.py +277 -0
- package/tools/build_page.py +4 -2
- package/tools/build_site.py +4 -2
- package/tools/copy_styles.py +4 -2
- package/tools/mosaic_config.py +71 -0
- package/tools/probe.py +4 -2
- package/tools/probe_accordion.py +4 -2
- package/tools/sweep_components.py +4 -2
- package/tools/sweep_interactions.py +4 -2
- package/tools/sweep_node_properties.py +4 -2
- package/tools/sweep_node_types.py +4 -2
- package/tools/sweep_properties.py +4 -2
- package/tools/sweep_style_properties.py +4 -2
- package/tools/sweep_style_states.py +4 -2
- package/tools/theme_zip.py +6 -3
- package/tools/verify_browser.py +4 -2
- package/tools/verify_intro.py +4 -2
- package/tools/verify_rwd.py +4 -2
- package/sites/moksa.json +0 -63681
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""The dialog lab: nine modals, every one a different answer, each explaining itself.
|
|
2
|
+
|
|
3
|
+
`references/dialog-and-triggers.md` documents 1.0.9's modal in prose and
|
|
4
|
+
`data/dialog-verification.csv` proves the mechanism works. Neither shows what the
|
|
5
|
+
thing can LOOK like, and that turns out to be the question people actually have -
|
|
6
|
+
"a modal" in most builders means one centred card, because the builder owns the
|
|
7
|
+
chrome. In Mosaic the host is a full-viewport `position:fixed; inset:0` layer with
|
|
8
|
+
`pointer-events:none`, and the window is an ordinary flex child of it. Every form
|
|
9
|
+
below - sheet, drawer, toast, takeover - is that one fact spent differently, with
|
|
10
|
+
no new node type and no custom CSS - just where the window is pinned inside it.
|
|
11
|
+
|
|
12
|
+
One trap is load-bearing enough to state up front: **the host must never set
|
|
13
|
+
`display`.** A `<dialog>`'s open/closed visibility IS its display property, so an
|
|
14
|
+
author-level `display:flex` on the modal beats `dialog:not([open]){display:none}`
|
|
15
|
+
and pins every modal open forever. This page was built that way first and all ten
|
|
16
|
+
sat on screen permanently; the forms now come from positioning the window, which
|
|
17
|
+
leaves `display` alone.
|
|
18
|
+
|
|
19
|
+
So the page is a showcase and its own documentation: each modal states the trigger,
|
|
20
|
+
run rule, memory scope and dismissal it was built with, in the same words the
|
|
21
|
+
reference uses, and the card that opens it says the same thing before you click.
|
|
22
|
+
|
|
23
|
+
Two of them are measurements as much as demonstrations:
|
|
24
|
+
|
|
25
|
+
- the TOAST carries no `modal-overlay`, which is the only way to get a modal that
|
|
26
|
+
does not block the page - `aria-modal` is emergent from the overlay's presence,
|
|
27
|
+
so leaving it out is a deliberate accessibility statement, not an omission;
|
|
28
|
+
- the LATCH puts `pickOne` on a CLICK interaction, which no run had done before.
|
|
29
|
+
It does not behave like the `pageLoad` case, and the page now says so: the draw
|
|
30
|
+
is over whoever evaluates TOGETHER, so a click - which delivers one contender at
|
|
31
|
+
a time - makes the first one clicked the winner and kills the rest.
|
|
32
|
+
|
|
33
|
+
The toast is the other one. It was built to show a modal with no overlay, and the
|
|
34
|
+
measurement said there is no such thing: Mosaic's heal pass inserts an overlay when
|
|
35
|
+
the author leaves it out, so `aria-modal="true"` cannot be shed and every modal
|
|
36
|
+
blocks page scrolling. The page keeps the form and tells that story instead, which
|
|
37
|
+
is the more useful one.
|
|
38
|
+
"""
|
|
39
|
+
import uuid
|
|
40
|
+
|
|
41
|
+
from _moksa import (CJK, DISPLAY, FOCUS_RING, MONO, RULE, T, bp, box, grid, ml,
|
|
42
|
+
mono, section, wrap)
|
|
43
|
+
|
|
44
|
+
U = lambda: str(uuid.uuid4())
|
|
45
|
+
|
|
46
|
+
# One pinned node id per modal. The opener and the modal are written in the same
|
|
47
|
+
# commit, so the button has to know the id before either row exists;
|
|
48
|
+
# build_page.refresh_node_ids() re-mints them all at build time, which is what
|
|
49
|
+
# keeps this module safe to run twice.
|
|
50
|
+
IDS = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _id(key):
|
|
54
|
+
return IDS.setdefault(key, U())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------- small pieces
|
|
58
|
+
|
|
59
|
+
def _btn(attr, label, actions, accent=False):
|
|
60
|
+
"""A button whose click runs `actions`. Accent = the primary, outline = quiet."""
|
|
61
|
+
ix = U()
|
|
62
|
+
return {
|
|
63
|
+
"type": "button",
|
|
64
|
+
"data": {"attrID": attr,
|
|
65
|
+
"interactions": [{"type": "click", "uuid": ix,
|
|
66
|
+
"clickOptions": {"name": attr, "ID": U(),
|
|
67
|
+
"actionSlots": {"click": {"actions": actions}}}}]},
|
|
68
|
+
"style": {"&": {"_": {
|
|
69
|
+
"backgroundColor": {"token": "--mk-accent"} if accent else "rgba(0,0,0,0)",
|
|
70
|
+
"color": {"token": "--mk-ink"},
|
|
71
|
+
"fontFamily": MONO, "fontSize": "11px", "letterSpacing": "0.18em",
|
|
72
|
+
"fontWeight": "500", "cursor": "pointer",
|
|
73
|
+
"paddingLeft": "18px", "paddingRight": "18px",
|
|
74
|
+
"paddingTop": "11px", "paddingBottom": "11px",
|
|
75
|
+
"customDeclarations": "border:1px solid %s;" % (
|
|
76
|
+
"rgb(255,90,54)" if accent else "rgba(22,24,28,.28)")}},
|
|
77
|
+
"hover": {"_": {"backgroundColor": {"token": "--mk-ink"},
|
|
78
|
+
"color": {"token": "--mk-paper"},
|
|
79
|
+
"customDeclarations": "border:1px solid rgb(22,24,28);"}},
|
|
80
|
+
"focus-visible": {"_": dict(FOCUS_RING)}},
|
|
81
|
+
"children": [{"type": "wysiwyg-text", "data": {"text": label}}]}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def open_btn(key, label="開啟", accent=True):
|
|
85
|
+
return _btn("dl-open-" + key, label, [
|
|
86
|
+
{"type": "modalOpen", "uuid": U(),
|
|
87
|
+
"settings": {"target": {"type": "element", "uuid": _id(key)}}}], accent)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def close_btn(key, label="關閉"):
|
|
91
|
+
return _btn("dl-close-" + key, label, [
|
|
92
|
+
{"type": "modalClose", "uuid": U(),
|
|
93
|
+
"settings": {"target": {"type": "element", "uuid": _id(key)}}}], False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def spec_line(label, value):
|
|
97
|
+
"""One row of the little specification table every modal and card carries."""
|
|
98
|
+
return box("dl-spec-row", {
|
|
99
|
+
"display": "flex", "columnGap": "12px", "alignItems": "baseline",
|
|
100
|
+
"paddingTop": "5px", "paddingBottom": "5px",
|
|
101
|
+
"customDeclarations": "border-top:1px solid " + RULE + ";"}, [
|
|
102
|
+
box("dl-spec-k", {"width": "88px", "flexSizing": {"type": "none"}},
|
|
103
|
+
[mono(label, size="10px", color="--mk-faint", track="0.16em")]),
|
|
104
|
+
box("dl-spec-v", {"flexSizing": {"type": "custom", "customOptions": {"flexGrow": "1", "flexShrink": "1"}}, "minWidth": "0px"},
|
|
105
|
+
[mono(value, size="10px", color="--mk-ink", track="0.04em")])])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------- the modals
|
|
109
|
+
|
|
110
|
+
def make_modal(key, num, title, lead, body, specs, host, window,
|
|
111
|
+
closedby="everything", overlay="rgba(22,24,28,.62)",
|
|
112
|
+
shorthand=None, extra_children=(), window_m=None):
|
|
113
|
+
"""One modal.
|
|
114
|
+
|
|
115
|
+
`host` styles the full-viewport layer and is what decides WHERE the window
|
|
116
|
+
sits; `window` styles the card itself. Splitting them this way is the whole
|
|
117
|
+
point of the page - the forms differ almost entirely in `host`.
|
|
118
|
+
"""
|
|
119
|
+
data = {"attrID": "dl-" + key, "accessibleLabel": title,
|
|
120
|
+
"closedby": closedby}
|
|
121
|
+
if shorthand:
|
|
122
|
+
data["interactionShorthand"] = shorthand
|
|
123
|
+
|
|
124
|
+
# NOTHING here may set `display`. A <dialog>'s open/closed visibility IS its
|
|
125
|
+
# display property - the UA hides it with `dialog:not([open]){display:none}` -
|
|
126
|
+
# so an author-level `display:flex` on the host pins every modal open forever.
|
|
127
|
+
# The forms are therefore laid out by positioning the WINDOW inside this
|
|
128
|
+
# fixed, inset:0 layer, not by making the layer a flex container.
|
|
129
|
+
host_style = {"paddingTop": "24px", "paddingBottom": "24px",
|
|
130
|
+
"paddingLeft": "24px", "paddingRight": "24px"}
|
|
131
|
+
host_style.update(host)
|
|
132
|
+
assert "display" not in host_style, "a modal host that sets display never hides"
|
|
133
|
+
|
|
134
|
+
win_style = {"backgroundColor": {"token": "--mk-paper"},
|
|
135
|
+
"position": "absolute",
|
|
136
|
+
"flexDirection": "column", "rowGap": "0px",
|
|
137
|
+
"maxHeight": "100%", "overflow": "auto",
|
|
138
|
+
"paddingTop": "28px", "paddingBottom": "26px",
|
|
139
|
+
"paddingLeft": "30px", "paddingRight": "30px",
|
|
140
|
+
"customDeclarations": "border:1px solid rgb(22,24,28);"}
|
|
141
|
+
win_style.update(window)
|
|
142
|
+
|
|
143
|
+
children = []
|
|
144
|
+
if overlay:
|
|
145
|
+
children.append({"type": "modal-overlay",
|
|
146
|
+
"data": {"attrID": "dl-veil-" + key},
|
|
147
|
+
"style": {"&": {"_": {"backgroundColor": overlay}}}})
|
|
148
|
+
children.append(dict(box(
|
|
149
|
+
"dl-win-" + key, win_style,
|
|
150
|
+
[
|
|
151
|
+
box("dl-win-head-" + key, {
|
|
152
|
+
"display": "flex", "justifyContent": "space-between",
|
|
153
|
+
"alignItems": "baseline", "columnGap": "16px",
|
|
154
|
+
"paddingBottom": "12px",
|
|
155
|
+
"customDeclarations": "border-bottom:1px solid " + RULE + ";"},
|
|
156
|
+
[mono("MODAL %02d" % num, size="10px", color="--mk-faint", track="0.26em"),
|
|
157
|
+
mono(key.upper(), size="10px", color="--mk-accent-ink", track="0.2em")]),
|
|
158
|
+
ml("h2", title, fontFamily=DISPLAY, fontWeight="600", fontSize="26px",
|
|
159
|
+
lineHeight="1.2", letterSpacing="0.005em",
|
|
160
|
+
color={"token": "--mk-ink"}, marginTop="16px",
|
|
161
|
+
_m={"fontSize": "22px"}),
|
|
162
|
+
T("p", lead, fontFamily=CJK, fontSize="14px", lineHeight="1.8",
|
|
163
|
+
color={"token": "--mk-ink"}, marginTop="10px"),
|
|
164
|
+
T("p", body, fontFamily=CJK, fontSize="13px", lineHeight="1.9",
|
|
165
|
+
color={"token": "--mk-muted"}, marginTop="10px"),
|
|
166
|
+
box("dl-win-spec-" + key, {"marginTop": "18px"},
|
|
167
|
+
[spec_line(k, v) for k, v in specs]),
|
|
168
|
+
box("dl-win-foot-" + key,
|
|
169
|
+
{"display": "flex", "columnGap": "12px", "marginTop": "22px",
|
|
170
|
+
"flexWrap": "wrap", "alignItems": "center"},
|
|
171
|
+
list(extra_children) + [close_btn(key)]),
|
|
172
|
+
], _m=window_m), type="modal-window"))
|
|
173
|
+
|
|
174
|
+
return {"type": "modal", "data": data, "style": bp(host_style),
|
|
175
|
+
"children": children, "nodeID": _id(key)}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Centring an absolutely positioned window: pin it to the middle of the layer and
|
|
179
|
+
# pull it back by half its own size. `transform` is a structured value in Mosaic -
|
|
180
|
+
# one entry per function - so this is two entries, not a string.
|
|
181
|
+
def _mid():
|
|
182
|
+
return [{"type": "translateX", "translateXOptions": {"value": "-50%"}, "uuid": U()},
|
|
183
|
+
{"type": "translateY", "translateYOptions": {"value": "-50%"}, "uuid": U()}]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
CENTRE = {} # the host needs nothing at all now
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def centred(**extra):
|
|
190
|
+
st = {"top": "50%", "left": "50%", "transform": _mid(), "width": "100%"}
|
|
191
|
+
st.update(extra)
|
|
192
|
+
return st
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
EDGELESS = {"paddingTop": "0px", "paddingBottom": "0px",
|
|
196
|
+
"paddingLeft": "0px", "paddingRight": "0px"}
|
|
197
|
+
|
|
198
|
+
# Every entry is (key, number, title, lead, body, specs, host, window, kwargs).
|
|
199
|
+
# `specs` is repeated verbatim on the opening card, so the page can be read
|
|
200
|
+
# without opening anything - which is the only way a popup gallery is honest.
|
|
201
|
+
SPECS = []
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def M(key, num, title, lead, body, specs, host, window, **kw):
|
|
205
|
+
SPECS.append((key, num, title, specs, kw.get("_card", "按鈕開啟")))
|
|
206
|
+
kw.pop("_card", None)
|
|
207
|
+
return make_modal(key, num, title, lead, body, specs, host, window, **kw)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
CENTRE_MODAL = M(
|
|
211
|
+
"centre", 1, "置中對話框",
|
|
212
|
+
"最直接的一種,也是其他八種的基準。",
|
|
213
|
+
"Mosaic 的 modal 是真正的 <dialog>,由外掛自己的控制器用 show() 開啟。"
|
|
214
|
+
"因為是 show() 而不是 showModal(),瀏覽器原生的 light-dismiss 不會生效,"
|
|
215
|
+
"所以關閉方式寫在 data-mosaic-modal-closedby 屬性上,由控制器讀取。"
|
|
216
|
+
"另外 aria-modal=\"true\" 不是你寫的——它在 modal 帶有 overlay 子節點時才出現,"
|
|
217
|
+
"因為真正擋住頁面的是 overlay。",
|
|
218
|
+
[("觸發", "click → modalOpen"), ("執行規則", "無"), ("記憶", "無"),
|
|
219
|
+
("關閉", "everything(Esc、點外面、按鈕)"), ("遮罩", "有 → aria-modal=\"true\"")],
|
|
220
|
+
CENTRE, centred(maxWidth="520px"))
|
|
221
|
+
|
|
222
|
+
SHEET_MODAL = M(
|
|
223
|
+
"sheet", 2, "底部抽屜",
|
|
224
|
+
"同樣的三個節點,只是 host 改成靠下對齊。",
|
|
225
|
+
"modal 這層是 position:fixed; inset:0 的全視窗圖層,pointer-events:none;"
|
|
226
|
+
"modal-window 只是它的一個 flex 子元素。所以「抽屜」不是另一種元件,"
|
|
227
|
+
"只是把 window 絕對定位到 left:0; right:0; bottom:0——沒有新的節點型別,也沒有自訂 CSS。"
|
|
228
|
+
"特別不能把 host 做成 flex 容器:<dialog> 是靠 display 決定開關的,"
|
|
229
|
+
"在 host 上寫 display 會讓所有彈窗永遠關不起來。"
|
|
230
|
+
"手機上這是比置中卡片更合手的形態,拇指搆得到。",
|
|
231
|
+
[("觸發", "click → modalOpen"), ("執行規則", "無"), ("記憶", "無"),
|
|
232
|
+
("關閉", "clickOutside(只有點遮罩)"), ("排版", "window: left/right/bottom = 0")],
|
|
233
|
+
dict(EDGELESS, paddingTop="24px"),
|
|
234
|
+
{"left": "0px", "right": "0px", "bottom": "0px",
|
|
235
|
+
"marginLeft": "auto", "marginRight": "auto", "maxWidth": "720px",
|
|
236
|
+
"customDeclarations": "border:1px solid rgb(22,24,28);border-bottom:0;"},
|
|
237
|
+
closedby="clickOutside")
|
|
238
|
+
|
|
239
|
+
DRAWER_MODAL = M(
|
|
240
|
+
"drawer", 3, "側邊面板",
|
|
241
|
+
"靠右、滿高,適合放篩選條件或購物車。",
|
|
242
|
+
"一樣只動 window:top:0; right:0; bottom:0,高度自己就撒滿了,"
|
|
243
|
+
"window 自己把高度撐滿。這個版本的 closedby 設成 esc,"
|
|
244
|
+
"所以點遮罩不會關——鍵盤使用者有路可走,滑鼠使用者得找按鈕,"
|
|
245
|
+
"這是刻意的取捨,不是疏漏。",
|
|
246
|
+
[("觸發", "click → modalOpen"), ("執行規則", "無"), ("記憶", "無"),
|
|
247
|
+
("關閉", "esc(點外面無效)"), ("排版", "window: top/right/bottom = 0")],
|
|
248
|
+
dict(EDGELESS, paddingLeft="24px"),
|
|
249
|
+
{"top": "0px", "right": "0px", "bottom": "0px",
|
|
250
|
+
"maxWidth": "400px", "width": "100%",
|
|
251
|
+
"customDeclarations": "border:0;border-left:1px solid rgb(22,24,28);"},
|
|
252
|
+
closedby="esc")
|
|
253
|
+
|
|
254
|
+
TOAST_MODAL = M(
|
|
255
|
+
"toast", 4, "角落通知",
|
|
256
|
+
"整頁唯一會自己出現的一個——也是一個教訓。",
|
|
257
|
+
"這個彈窗的資料裡沒有寫 modal-overlay——但交付的 HTML 裡有一個。"
|
|
258
|
+
"Mosaic 在 heal 階段會自己補上:程式碼裡寫得很明白,"
|
|
259
|
+
"overlay 是「必要的結構元件,不可刪除,每個 modal 一個」,"
|
|
260
|
+
"沒有就補一個到子節點順序的最後。"
|
|
261
|
+
"所以 aria-modal=\"true\" 實際上是拿不掉的,"
|
|
262
|
+
"實測也確認:這個彈窗開著時,滑鼠滾輪同樣捲不動頁面。"
|
|
263
|
+
"角落通知是一種排版,不是一種「不阻擋」的 modal——"
|
|
264
|
+
"1.0.9 沒有不阻擋的 modal。真正不擋人的通知要用普通節點做。",
|
|
265
|
+
[("觸發", "pageLoad(interactionShorthand)"), ("執行規則", "cap max 1"),
|
|
266
|
+
("記憶", "session(sessionStorage,每個分頁各自計算)"),
|
|
267
|
+
("關閉", "everything"),
|
|
268
|
+
("遮罩", "未寫,但 heal 自動補上 → 仍然 aria-modal")],
|
|
269
|
+
{},
|
|
270
|
+
{"top": "90px", "right": "24px", "maxWidth": "330px", "width": "100%",
|
|
271
|
+
"paddingTop": "20px", "paddingBottom": "18px",
|
|
272
|
+
"paddingLeft": "20px", "paddingRight": "20px",
|
|
273
|
+
"customDeclarations": "border:1px solid rgb(22,24,28);"
|
|
274
|
+
"box-shadow:0 18px 44px rgba(22,24,28,.18);"},
|
|
275
|
+
shorthand={"type": "pageLoad",
|
|
276
|
+
"pageLoadOptions": {"runs": {"rules": [
|
|
277
|
+
{"uuid": U(), "type": "cap",
|
|
278
|
+
"capOptions": {"max": "1", "remember": "session",
|
|
279
|
+
"name": "dl-toast"}}]}}},
|
|
280
|
+
_card="載入後自己出現(每個分頁一次)")
|
|
281
|
+
|
|
282
|
+
TAKEOVER_MODAL = M(
|
|
283
|
+
"takeover", 5, "全幅接管",
|
|
284
|
+
"整個視窗都是它的,用在需要全神貫注的那一步。",
|
|
285
|
+
"window 設成 width:100%、height:100%、max-width:none,"
|
|
286
|
+
"host 的 padding 歸零。放大圖、開始引導流程、或是結帳前的最後確認會用這個形態。"
|
|
287
|
+
"注意 window 本身有 overflow-y:auto——內容再長也是在卡片裡捲,"
|
|
288
|
+
"背後的頁面不會跟著動(.m-modal 帶 overscroll-behavior:contain)。",
|
|
289
|
+
[("觸發", "click → modalOpen"), ("執行規則", "無"), ("記憶", "無"),
|
|
290
|
+
("關閉", "everything"), ("排版", "window: 100% × 100%")],
|
|
291
|
+
dict(EDGELESS),
|
|
292
|
+
{"top": "0px", "right": "0px", "bottom": "0px", "left": "0px",
|
|
293
|
+
"maxWidth": "none",
|
|
294
|
+
"paddingTop": "64px", "paddingBottom": "64px",
|
|
295
|
+
"paddingLeft": "64px", "paddingRight": "64px",
|
|
296
|
+
"customDeclarations": "border:0;"},
|
|
297
|
+
window_m={"paddingTop": "28px", "paddingBottom": "28px",
|
|
298
|
+
"paddingLeft": "22px", "paddingRight": "22px"})
|
|
299
|
+
|
|
300
|
+
GATE_MODAL = M(
|
|
301
|
+
"gate", 6, "閘門:唯一的出口在裡面",
|
|
302
|
+
"Esc 沒有用,點外面也沒有用。",
|
|
303
|
+
"closedby 設成 nothing 之後,控制器不接受任何外部關閉方式,"
|
|
304
|
+
"唯一的出路是你自己放在裡面的 modalClose 按鈕。"
|
|
305
|
+
"年齡確認、cookie 同意、必須先選語言的站台就是這個形態。"
|
|
306
|
+
"這也是為什麼 closedby 不是裝飾性設定——它決定了使用者有沒有退路,"
|
|
307
|
+
"用錯就是把人鎖在頁面上。",
|
|
308
|
+
[("觸發", "click → modalOpen"), ("執行規則", "無"), ("記憶", "無"),
|
|
309
|
+
("關閉", "nothing(只有裡面的按鈕)"), ("用途", "年齡確認/cookie 同意")],
|
|
310
|
+
CENTRE, centred(maxWidth="460px"),
|
|
311
|
+
closedby="nothing", overlay="rgba(22,24,28,.82)")
|
|
312
|
+
|
|
313
|
+
EXIT_MODAL = M(
|
|
314
|
+
"exit", 7, "離站挽留",
|
|
315
|
+
"游標往上離開頁面時出現——而且只有桌機會。",
|
|
316
|
+
"exitIntent 偵測的是指標從頁面頂端離開,觸控裝置上永遠不會觸發,"
|
|
317
|
+
"這是它的設計而不是缺陷:手機沒有「游標離開」這件事。"
|
|
318
|
+
"awayFor 600ms 是指標離開多久才算數。"
|
|
319
|
+
"最值得知道的是它的先天限制:"
|
|
320
|
+
"你為了離開而把游標往上移(去按上一頁、關分頁、打網址),"
|
|
321
|
+
"那個動作本身就是 exitIntent 的定義——"
|
|
322
|
+
"等它滿 600ms 觸發時,你已經停在那顆按鈕上了,"
|
|
323
|
+
"所以彈窗常常只來得及一閃就跟著頁面一起消失。"
|
|
324
|
+
"實測過:這不是彈窗自己關掉,是導覽把整個頁面帶走了。"
|
|
325
|
+
"所以 exitIntent 真正接得到的,是游標漂上去但還沒決定要走的人;"
|
|
326
|
+
"已經按下上一頁的人,你是攔不到的。"
|
|
327
|
+
"這張卡片因此多給了一顆「手動開啟」,不然你永遠讀不完它。",
|
|
328
|
+
[("觸發", "exitIntent,awayFor 600ms(僅桌機)"), ("執行規則", "cap max 1"),
|
|
329
|
+
("記憶", "session"), ("關閉", "everything"),
|
|
330
|
+
("限制", "按上一頁的人攔不到,只會一閃")],
|
|
331
|
+
CENTRE, centred(maxWidth="480px"),
|
|
332
|
+
shorthand={"type": "exitIntent",
|
|
333
|
+
"exitIntentOptions": {
|
|
334
|
+
"settings": {"awayFor": "600ms"},
|
|
335
|
+
"runs": {"rules": [
|
|
336
|
+
{"uuid": U(), "type": "cap",
|
|
337
|
+
"capOptions": {"max": "1", "remember": "session",
|
|
338
|
+
"name": "dl-exit"}}]}}},
|
|
339
|
+
_card="游標離開頂端時自動出現(桌機),或:")
|
|
340
|
+
|
|
341
|
+
DEPTH_MODAL = M(
|
|
342
|
+
"depth", 8, "讀到一半才問",
|
|
343
|
+
"捲到 75% 才出現,而且問過之後一天內不再問。",
|
|
344
|
+
"scrollDepth 的 threshold 是百分比,cooldown 則讓同一條規則在時間內不再跑。"
|
|
345
|
+
"remember 設成 forever 表示這個紀錄寫進 localStorage,"
|
|
346
|
+
"關掉瀏覽器再回來仍然算數——跟 session 的差別在這裡。"
|
|
347
|
+
"「讀完才問」比「一進來就問」轉換率高,而且比較不惹人厭。",
|
|
348
|
+
[("觸發", "scrollDepth,threshold 75%"), ("執行規則", "cooldown 1 day"),
|
|
349
|
+
("記憶", "forever(localStorage,跨瀏覽保留)"),
|
|
350
|
+
("關閉", "clickOutside"), ("紀錄", "mos:v1:r:dl-depth")],
|
|
351
|
+
{},
|
|
352
|
+
{"left": "24px", "bottom": "24px", "maxWidth": "380px", "width": "100%"},
|
|
353
|
+
closedby="clickOutside",
|
|
354
|
+
shorthand={"type": "scrollDepth",
|
|
355
|
+
"scrollDepthOptions": {
|
|
356
|
+
"settings": {"threshold": "75%"},
|
|
357
|
+
"runs": {"rules": [
|
|
358
|
+
{"uuid": U(), "type": "cooldown",
|
|
359
|
+
"cooldownOptions": {"duration": "1", "durationUnit": "day",
|
|
360
|
+
"remember": "forever",
|
|
361
|
+
"name": "dl-depth"}}]}}},
|
|
362
|
+
_card="捲到 75% 時(一天一次)")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# -- the latch -----------------------------------------------------------------
|
|
366
|
+
#
|
|
367
|
+
# This started out as an A/B test and the measurement said otherwise, which is why
|
|
368
|
+
# it is here. Two buttons, one modal each, both click interactions carrying the same
|
|
369
|
+
# `pickOne` group.
|
|
370
|
+
#
|
|
371
|
+
# On `pageLoad`, pickOne is a real lottery: three modals sharing a group were loaded
|
|
372
|
+
# 34 times and the winner moved around (4/6/2). On `click` it is not. Measured here,
|
|
373
|
+
# 6 fresh visitors each way: click B first and B opens, after which A does nothing;
|
|
374
|
+
# click A first and the reverse. The first interaction to EVALUATE records itself as
|
|
375
|
+
# the winner, and every other member of the group is suppressed for as long as
|
|
376
|
+
# `remember` holds.
|
|
377
|
+
#
|
|
378
|
+
# So pickOne draws among the contenders that evaluate TOGETHER. pageLoad schedules
|
|
379
|
+
# them all in one pass; a click delivers one at a time, so the draw has a field of
|
|
380
|
+
# one. That makes click-pickOne a latch - "whichever of these the visitor takes
|
|
381
|
+
# first, the others stop offering" - which is useful, but it is not an A/B test.
|
|
382
|
+
|
|
383
|
+
def _latch(key, num, title, tint, other):
|
|
384
|
+
return M(
|
|
385
|
+
key, num, title,
|
|
386
|
+
"你剛剛按了「%s」——現在另一顆按鈕已經沒有反應了。" % title,
|
|
387
|
+
"兩顆按鈕的 click 互動共用同一個 pickOne 群組 dl-ab,"
|
|
388
|
+
"而群組裡只會有一個真的跑。"
|
|
389
|
+
"重點是「哪一個」怎麼決定:"
|
|
390
|
+
"實測 6 個全新訪客,先按哪一顆就是哪一顆贏:"
|
|
391
|
+
"先按「" + other + "」,那這一個就永遠不會開了——不是隨機,"
|
|
392
|
+
"是「先被評估的先寫入紀錄」。"
|
|
393
|
+
"同一條規則放在 pageLoad 上則是真的抽籤(實測 34 次,"
|
|
394
|
+
"三個版本 4/6/2),因為載入時它們是同一批排程的。"
|
|
395
|
+
"換句話說:pickOne 只在「同時被評估」的競爭者之間抽。"
|
|
396
|
+
"所以 click + pickOne 是一個閂鎖,不是 A/B 測試:"
|
|
397
|
+
"「訪客先選了哪條路,其他就不再提議」。",
|
|
398
|
+
[("觸發", "click → modalOpen"),
|
|
399
|
+
("執行規則", "pickOne,群組 dl-ab"),
|
|
400
|
+
("記憶", "page(換頁重置)"),
|
|
401
|
+
("關閉", "everything"),
|
|
402
|
+
("實測", "先按的那顆贏,6/6 兩個方向都是")],
|
|
403
|
+
CENTRE,
|
|
404
|
+
centred(maxWidth="440px", **{"customDeclarations": "border:1px solid rgb(22,24,28);"
|
|
405
|
+
"border-top:6px solid %s;" % tint}))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
AB_A = _latch("aba", 9, "選項 A", "rgb(255,90,54)", "B")
|
|
409
|
+
AB_B = _latch("abb", 9, "選項 B", "rgb(31,58,95)", "A")
|
|
410
|
+
SPECS.pop() # the pair occupies one card, not two
|
|
411
|
+
SPECS.pop()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def latch_button(key, label, tint):
|
|
415
|
+
"""One button, one modal, and a pickOne rule shared with its twin."""
|
|
416
|
+
return {"type": "button",
|
|
417
|
+
"data": {"attrID": "dl-open-" + key,
|
|
418
|
+
"interactions": [{
|
|
419
|
+
"type": "click", "uuid": U(),
|
|
420
|
+
"clickOptions": {
|
|
421
|
+
"name": "dl-ab-" + key, "ID": U(),
|
|
422
|
+
"runs": {"rules": [{"uuid": U(), "type": "pickOne",
|
|
423
|
+
"pickOneOptions": {"remember": "page",
|
|
424
|
+
"name": "dl-ab"}}]},
|
|
425
|
+
"actionSlots": {"click": {"actions": [
|
|
426
|
+
{"type": "modalOpen", "uuid": U(),
|
|
427
|
+
"settings": {"target": {"type": "element",
|
|
428
|
+
"uuid": _id(key)}}}]}}}}]},
|
|
429
|
+
"style": {"&": {"_": {
|
|
430
|
+
"backgroundColor": "rgba(0,0,0,0)", "color": {"token": "--mk-ink"},
|
|
431
|
+
"fontFamily": MONO, "fontSize": "11px", "letterSpacing": "0.18em",
|
|
432
|
+
"fontWeight": "500", "cursor": "pointer",
|
|
433
|
+
"paddingLeft": "16px", "paddingRight": "16px",
|
|
434
|
+
"paddingTop": "11px", "paddingBottom": "11px",
|
|
435
|
+
"customDeclarations": "border:1px solid %s;" % tint}},
|
|
436
|
+
"hover": {"_": {"backgroundColor": {"token": "--mk-ink"},
|
|
437
|
+
"color": {"token": "--mk-paper"},
|
|
438
|
+
"customDeclarations": "border:1px solid rgb(22,24,28);"}},
|
|
439
|
+
"focus-visible": {"_": dict(FOCUS_RING)}},
|
|
440
|
+
"children": [{"type": "wysiwyg-text", "data": {"text": label}}]}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
MODALS = [CENTRE_MODAL, SHEET_MODAL, DRAWER_MODAL, TOAST_MODAL, TAKEOVER_MODAL,
|
|
444
|
+
GATE_MODAL, EXIT_MODAL, DEPTH_MODAL, AB_A, AB_B]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------- the page
|
|
448
|
+
|
|
449
|
+
# Self-opening cards that ALSO need a button, because their trigger cannot be
|
|
450
|
+
# performed on demand by a reader who wants to look at the thing.
|
|
451
|
+
ALSO_BUTTON = {"exit"}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def card(key, num, title, specs, how):
|
|
455
|
+
"""The grid cell that opens one modal - and states its configuration first.
|
|
456
|
+
|
|
457
|
+
A gallery of popups that only says "click me" teaches nothing; the reader
|
|
458
|
+
should be able to take the configuration away without ever opening one.
|
|
459
|
+
"""
|
|
460
|
+
self_opening = how != "按鈕開啟"
|
|
461
|
+
return box("dl-card-" + key, {
|
|
462
|
+
"display": "flex", "flexDirection": "column", "rowGap": "0px",
|
|
463
|
+
"backgroundColor": {"token": "--mk-paper"},
|
|
464
|
+
"paddingTop": "22px", "paddingBottom": "20px",
|
|
465
|
+
"paddingLeft": "22px", "paddingRight": "22px",
|
|
466
|
+
"customDeclarations": "border:1px solid " + RULE + ";"},
|
|
467
|
+
[
|
|
468
|
+
box("dl-card-head-" + key, {
|
|
469
|
+
"display": "flex", "justifyContent": "space-between",
|
|
470
|
+
"alignItems": "baseline", "columnGap": "12px"},
|
|
471
|
+
[mono("%02d" % num, size="10px", color="--mk-faint", track="0.26em"),
|
|
472
|
+
mono(key.upper(), size="10px", color="--mk-accent-ink", track="0.18em")]),
|
|
473
|
+
ml("h3", title, fontFamily=DISPLAY, fontWeight="600", fontSize="19px",
|
|
474
|
+
lineHeight="1.25", letterSpacing="0.005em",
|
|
475
|
+
color={"token": "--mk-ink"}, marginTop="12px"),
|
|
476
|
+
box("dl-card-spec-" + key, {"marginTop": "14px", "flexSizing": {"type": "grow"}, "minWidth": "0px"},
|
|
477
|
+
[spec_line(k, v) for k, v in specs[:3]]),
|
|
478
|
+
box("dl-card-foot-" + key,
|
|
479
|
+
{"marginTop": "18px", "display": "flex", "columnGap": "12px",
|
|
480
|
+
"rowGap": "8px", "flexWrap": "wrap", "alignItems": "center"},
|
|
481
|
+
([] if not self_opening
|
|
482
|
+
else [mono(how, size="10px", color="--mk-muted", track="0.1em")])
|
|
483
|
+
# exitIntent fires on the gesture of reaching for the browser
|
|
484
|
+
# chrome, so on a page you are about to leave you only ever glimpse
|
|
485
|
+
# it. On a showcase page that is the same as not shipping it, so
|
|
486
|
+
# this one gets a manual opener as well as its real trigger.
|
|
487
|
+
+ ([open_btn(key, "手動開啟", accent=False)]
|
|
488
|
+
if key in ALSO_BUTTON else
|
|
489
|
+
([] if self_opening else [open_btn(key)]))),
|
|
490
|
+
],
|
|
491
|
+
hover={"customDeclarations": "border:1px solid rgb(22,24,28);"})
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
HEAD = section("dl-head", [wrap("dl-head-w", [
|
|
495
|
+
box("dl-head-rule", {"paddingTop": "76px"}, []),
|
|
496
|
+
mono("DIALOG LAB / 彈窗實驗室", size="11px", color="--mk-faint", track="0.26em"),
|
|
497
|
+
ml("h1", "一個節點,\n九種答案。",
|
|
498
|
+
fontFamily=DISPLAY, fontWeight="600", fontSize="62px", lineHeight="1.06",
|
|
499
|
+
letterSpacing="0.005em", color={"token": "--mk-ink"}, marginTop="18px",
|
|
500
|
+
_t={"fontSize": "48px"}, _m={"fontSize": "34px"}),
|
|
501
|
+
T("p",
|
|
502
|
+
"Mosaic 的 modal 是真正的 <dialog>:一層 position:fixed; inset:0 的全視窗圖層,"
|
|
503
|
+
"裡面放一個 modal-overlay 和一個 modal-window。"
|
|
504
|
+
"下面每一種形態都只是把這兩個子元素擺在不同位置,沒有新的節點型別、"
|
|
505
|
+
"沒有外掛、也沒有一行自己寫的 JavaScript。",
|
|
506
|
+
fontFamily=CJK, fontSize="16px", lineHeight="1.95",
|
|
507
|
+
color={"token": "--mk-ink"}, marginTop="22px", maxWidth="640px"),
|
|
508
|
+
T("p",
|
|
509
|
+
"每個彈窗打開後都會說明自己示範的是什麼機制,"
|
|
510
|
+
"卡片上也先把觸發方式、執行規則、記憶範圍寫出來——"
|
|
511
|
+
"不按也讀得完。",
|
|
512
|
+
fontFamily=CJK, fontSize="14px", lineHeight="1.9",
|
|
513
|
+
color={"token": "--mk-muted"}, marginTop="12px", maxWidth="640px"),
|
|
514
|
+
box("dl-head-pad", {"paddingTop": "116px"}, [], _m={"paddingTop": "26px"}),
|
|
515
|
+
])], pad_y="0px")
|
|
516
|
+
|
|
517
|
+
GALLERY = section("dl-gallery", [wrap("dl-gallery-w", [
|
|
518
|
+
grid("dl-grid", 3, "18px",
|
|
519
|
+
[card(k, n, t, s, how) for k, n, t, s, how in SPECS] +
|
|
520
|
+
[box("dl-card-ab", {
|
|
521
|
+
"display": "flex", "flexDirection": "column", "rowGap": "0px",
|
|
522
|
+
"backgroundColor": {"token": "--mk-panel"},
|
|
523
|
+
"paddingTop": "22px", "paddingBottom": "20px",
|
|
524
|
+
"paddingLeft": "22px", "paddingRight": "22px",
|
|
525
|
+
"customDeclarations": "border:1px solid rgb(22,24,28);"},
|
|
526
|
+
[
|
|
527
|
+
box("dl-card-head-ab", {
|
|
528
|
+
"display": "flex", "justifyContent": "space-between",
|
|
529
|
+
"alignItems": "baseline", "columnGap": "12px"},
|
|
530
|
+
[mono("09", size="10px", color="--mk-faint", track="0.26em"),
|
|
531
|
+
mono("PICKONE", size="10px", color="--mk-accent-ink", track="0.18em")]),
|
|
532
|
+
ml("h3", "先按先贏的閂鎖",
|
|
533
|
+
fontFamily=DISPLAY, fontWeight="600", fontSize="19px",
|
|
534
|
+
lineHeight="1.25", letterSpacing="0.005em",
|
|
535
|
+
color={"token": "--mk-ink"}, marginTop="12px"),
|
|
536
|
+
box("dl-card-spec-ab", {"marginTop": "14px", "flexSizing": {"type": "grow"}, "minWidth": "0px"},
|
|
537
|
+
[spec_line("觸發", "click,兩顆各自一個"),
|
|
538
|
+
spec_line("執行規則", "pickOne,群組 dl-ab"),
|
|
539
|
+
spec_line("實測", "先按的那顆贏,另一顆失效")]),
|
|
540
|
+
box("dl-card-foot-ab",
|
|
541
|
+
{"marginTop": "18px", "display": "flex", "columnGap": "10px",
|
|
542
|
+
"flexWrap": "wrap"},
|
|
543
|
+
[latch_button("aba", "選項 A", "rgb(255,90,54)"),
|
|
544
|
+
latch_button("abb", "選項 B", "rgb(31,58,95)")]),
|
|
545
|
+
])],
|
|
546
|
+
tcols=2, mcols=1),
|
|
547
|
+
box("dl-gallery-pad", {"paddingTop": "64px"}, []),
|
|
548
|
+
])], bg="--mk-panel", pad_y="0px")
|
|
549
|
+
|
|
550
|
+
NOTE = section("dl-note", [wrap("dl-note-w", [
|
|
551
|
+
box("dl-note-inner", {
|
|
552
|
+
"paddingTop": "54px", "paddingBottom": "80px",
|
|
553
|
+
"customDeclarations": "border-top:1px solid " + RULE + ";"},
|
|
554
|
+
[
|
|
555
|
+
mono("WHY IT MATTERS", size="10px", color="--mk-faint", track="0.26em"),
|
|
556
|
+
T("p",
|
|
557
|
+
"在多數的頁面編輯器裡「彈窗」是一個現成元件,"
|
|
558
|
+
"長相由外掛決定,你能改的是裡面的文字和幾個開關。"
|
|
559
|
+
"Mosaic 沒有那層外殼——modal、modal-overlay、modal-window "
|
|
560
|
+
"是三個可以自由排版的節點,所以上面九種形態全部來自同一組資料,"
|
|
561
|
+
"差別只在 window 被釘在哪裡。順帶一個踩過的坑:"
|
|
562
|
+
"host 不能設 display——<dialog> 是靠 display 決定開關的,"
|
|
563
|
+
"寫了 display:flex 就等於把每一個彈窗永遠打開。",
|
|
564
|
+
fontFamily=CJK, fontSize="15px", lineHeight="1.95",
|
|
565
|
+
color={"token": "--mk-ink"}, marginTop="16px", maxWidth="680px"),
|
|
566
|
+
T("p",
|
|
567
|
+
"另一半是行為。觸發(pageLoad/exitIntent/scrollDepth/click)、"
|
|
568
|
+
"執行規則(cap/cooldown/firstRunWindow/pickOne)、"
|
|
569
|
+
"記憶範圍(page/session/forever)和關閉方式"
|
|
570
|
+
"(everything/clickOutside/esc/nothing)是四個獨立的軸,"
|
|
571
|
+
"各自組合——而且整組設定就寫在該彈窗自己的資料裡,"
|
|
572
|
+
"不需要在頁面上放任何觸發元件,兩個頁面也可以完全不同。",
|
|
573
|
+
fontFamily=CJK, fontSize="15px", lineHeight="1.95",
|
|
574
|
+
color={"token": "--mk-muted"}, marginTop="14px", maxWidth="680px"),
|
|
575
|
+
]),
|
|
576
|
+
])], pad_y="0px")
|
|
577
|
+
|
|
578
|
+
TREE = {"type": "div", "data": {"attrID": "dl-page"},
|
|
579
|
+
"children": [HEAD, GALLERY, NOTE] + MODALS}
|