mosaic-headless 1.22.0 → 1.23.1
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/SKILL.md +3 -1
- 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/data/dialog-form-verification.csv +50 -0
- package/data/interaction-rules-verification.csv +4 -0
- package/package.json +1 -1
- package/references/dialog-and-triggers.md +105 -3
- package/references/failure-modes.md +26 -0
- package/references/styling.md +29 -0
- package/scripts/check-release.mjs +2 -1
- package/sites/_moksa.py +12 -1
- package/sites/moksa.json +12939 -2
- package/tools/build_page.py +20 -0
- package/tools/verify_dialog_lab.py +348 -0
package/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: |
|
|
|
4
4
|
Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface with `mo.py`, which joins every source table to the live sweeps so a lookup leads with the measured verdict rather than the declaration (126 node types, 191 properties, 98 style properties with 20 structured value shapes pinned down, 53 style states, 156 variants, 74 dynamic variables, 15 interaction triggers, 115 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, the design-token and element-class layers verified against compiled CSS, the @VAR() dynamic language verified against rendered output, nine designed pages built through the tables themselves, and the delivered pages re-read in Chromium at three viewports so a rule that is present, correct and still wrong cannot pass. Drives Mosaic's own theme export/import and its plugin data upgrade from outside the editor and holds the copy against the source tree for tree. Measured on Mosaic Pro 1.0.9: the modal (`<dialog>`), OpenStreetMap, the exit-intent and scroll-depth triggers, run rules and the memory actions all built on a live page and read back, and the 1.0.7 -> 1.0.8 -> 1.0.9 migrations driven from outside wp-admin.
|
|
5
5
|
license: "MIT"
|
|
6
6
|
author: "moksa (https://moksaweb.com)"
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.23.1"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Headless Mosaic
|
|
@@ -533,6 +533,7 @@ so the pattern is in the data, not just in this paragraph.
|
|
|
533
533
|
| `data/navigation-verification.csv` | 46 | **fetched and opened live** - every menu item is a real `<a href>`, every page of the nineteen answers 200, and no page scrolls sideways at 390 / 768 / 1280 |
|
|
534
534
|
| `data/page-modal-verification.csv` | 19 | **read live** - one modal per page, and what each one's trigger, run rule, memory scope and dismissal policy actually came out as |
|
|
535
535
|
| `data/dialog-verification.csv` | 19 | **driven live** - the 1.0.9 modal, its triggers, its run rules and the memory actions, each exercised in a real browser |
|
|
536
|
+
| `data/dialog-form-verification.csv` | 49 | **driven live** - the same three nodes made into five FORMS (sheet, drawer, corner, takeover, gate), each re-opened at 390px and 768px; also where `pickOne` on a click turned out to be a latch rather than a lottery |
|
|
536
537
|
| `data/custom-fields-verification.csv` | 62 | **rendered live** - ACF and Meta Box fields of every common type read back through `@VAR` / `@LOOP` off the delivered page, loops included |
|
|
537
538
|
| `data/theme-zip-verification.csv` | 22 | **round-tripped live** - Mosaic's own ZIP export imported in test mode and compared to its source, table by table and tree by tree |
|
|
538
539
|
| `data/node-type-notes.csv` | 8 | where a sweep outcome is true but misleading on its own, why. Surfaced by `mo.py type` |
|
|
@@ -807,6 +808,7 @@ post — `build_all.py` resets first for that reason.
|
|
|
807
808
|
| `verify_slider.py` | the slider in a real browser: does it advance, do the arrows and bullets drive it, does the keyboard, does it stop when motion is not wanted |
|
|
808
809
|
| `verify_navigation.py` | a multi-page site: is every menu item a real `<a href>`, does every page answer 200 (a template that was never bound answers 406 with an empty body, which only a logged-out visitor sees), and with `--viewports` does any page scroll SIDEWAYS - the one question no per-page checker asks |
|
|
809
810
|
| `verify_dialog.py` | the 1.0.9 modal in a real browser: does it open, close, answer Esc, remember, cap - and does an OpenStreetMap carry the coordinates it was given |
|
|
811
|
+
| `verify_dialog_lab.py` | the question after "does the modal work": can three nodes be made into a bottom sheet, a side drawer, a corner notice, a full takeover and a gate with no way out - and does each one still fit at phone width, which the page's own width check cannot see |
|
|
810
812
|
| `list_fields.php` | every `@VAR` / `@LOOP` name Mosaic registers for one post - custom fields, their derived `__label` / `__url` / `__id` properties, the row variables of each loop - with the value each resolves to (`wp eval-file`) |
|
|
811
813
|
| `data_upgrade.py` | after a plugin update, run Mosaic's data migration over its own milestone route - the step wp-admin does from a screen - and set the config's version when the editor API is back |
|
|
812
814
|
| `copy_styles.py` | push one node's style onto others, by attrID or prefix |
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"author": "moksa (https://moksaweb.com)",
|
|
23
|
-
"version": "1.
|
|
23
|
+
"version": "1.23.1"
|
|
24
24
|
},
|
|
25
25
|
"loaderBehaviour": "Upload via Settings -> Skills -> Upload. Claude.ai parses SKILL.md frontmatter and surfaces the skill in your library. The extraction tool (extract-block-schema.php) needs a live WP-CLI connection and won't run in the sandbox; use it from a local terminal against your own site instead.",
|
|
26
26
|
"uploadSteps": [
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"author": "moksa (https://moksaweb.com)",
|
|
23
|
-
"version": "1.
|
|
23
|
+
"version": "1.23.1"
|
|
24
24
|
},
|
|
25
25
|
"loaderBehaviour": "Auto-loads on session start when SKILL.md frontmatter parses successfully.",
|
|
26
26
|
"verified": true,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"author": "moksa (https://moksaweb.com)",
|
|
23
|
-
"version": "1.
|
|
23
|
+
"version": "1.23.1"
|
|
24
24
|
},
|
|
25
25
|
"loaderBehaviour": "Confirmed (2026-07-11): Codex CLI natively supports the SKILL.md spec. Place SKILL.md under .codex/skills/<name>/ (project) or ~/.codex/skills/<name>/ (personal) and Codex loads the name+description at session start, then the full body on demand. A parallel, broader convention .agents/skills/ (searched from cwd up to repo root, then ~/.agents/skills/) also exists across multiple tools - if your Codex CLI version prioritizes that path instead, mirror the same SKILL.md there.",
|
|
26
26
|
"verified": true,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"author": "moksa (https://moksaweb.com)",
|
|
23
|
-
"version": "1.
|
|
23
|
+
"version": "1.23.1"
|
|
24
24
|
},
|
|
25
25
|
"loaderBehaviour": "CHANGED as of 2026-07-11: GitHub Copilot added a proper '.github/skills/' Agent Skills directory (December 2025), alongside the older single-file .github/copilot-instructions.md convention. This config targets the new skills-directory form. If your Copilot version predates this (pre Dec 2025), use the instructions-append fallback instead (see fallback below).",
|
|
26
26
|
"fallback": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"author": "moksa (https://moksaweb.com)",
|
|
23
|
-
"version": "1.
|
|
23
|
+
"version": "1.23.1"
|
|
24
24
|
},
|
|
25
25
|
"loaderBehaviour": "CHANGED as of 2026-07-11: Gemini CLI now natively supports the same SKILL.md standard as Claude Code and Codex CLI - the same directory-based skill works unmodified. Gemini CLI discovers skills in this precedence order: built-in, extension skills, ~/.gemini/skills/ (personal), .gemini/skills/ (project, shared via version control). At session start Gemini injects each discovered skill's name+description into the system prompt and calls activate_skill when a task matches.",
|
|
26
26
|
"verified": true,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
check,result,detail
|
|
2
|
+
HOST/centre,PASS,"['dialog', 'everything']"
|
|
3
|
+
HOST/sheet,PASS,"['dialog', 'clickOutside']"
|
|
4
|
+
HOST/drawer,PASS,"['dialog', 'esc']"
|
|
5
|
+
HOST/toast,PASS,"['dialog', 'everything']"
|
|
6
|
+
HOST/takeover,PASS,"['dialog', 'everything']"
|
|
7
|
+
HOST/gate,PASS,"['dialog', 'nothing']"
|
|
8
|
+
HOST/exit,PASS,"['dialog', 'everything']"
|
|
9
|
+
HOST/depth,PASS,"['dialog', 'clickOutside']"
|
|
10
|
+
HOST/aba,PASS,"['dialog', 'everything']"
|
|
11
|
+
HOST/abb,PASS,"['dialog', 'everything']"
|
|
12
|
+
TOAST/pageLoad,PASS,opened on load
|
|
13
|
+
TOAST/overlay-synthesised,PASS,authored none; heal inserted one
|
|
14
|
+
TOAST/aria-modal-unavoidable,PASS,aria-modal='true' despite no authored overlay
|
|
15
|
+
TOAST/top-right,PASS,x=911 y=114 w=330 vw=1280
|
|
16
|
+
TOAST/wheel-blocked,PASS,"a corner toast is a LAYOUT, not a non-blocking modal"
|
|
17
|
+
CLOSED_IS_HIDDEN,PASS,every closed modal is display:none
|
|
18
|
+
CENTRE/centred,PASS,x=385 w=520 vw=1280
|
|
19
|
+
CENTRE/aria-modal,PASS,aria-modal='true' (overlay=True)
|
|
20
|
+
CENTRE/esc-closes,PASS,closedby=everything
|
|
21
|
+
SHEET/at-bottom,PASS,y+h=900 vh=900
|
|
22
|
+
SHEET/wide,PASS,w=720
|
|
23
|
+
SHEET/esc-does-nothing,PASS,"closedby=clickOutside, so Esc must not close it"
|
|
24
|
+
DRAWER/right-edge,PASS,x+w=1265 layer right=1265
|
|
25
|
+
DRAWER/full-height,PASS,h=900 vh=900
|
|
26
|
+
DRAWER/esc-closes,PASS,closedby=esc
|
|
27
|
+
TAKEOVER/fills-viewport,PASS,1265x900 vs layer 1265x900
|
|
28
|
+
GATE/esc-refused,PASS,closedby=nothing
|
|
29
|
+
GATE/outside-refused,PASS,clicking the overlay must not close it
|
|
30
|
+
GATE/button-closes,PASS,the modalClose inside is the only exit
|
|
31
|
+
DEPTH/shut-at-rest,PASS,
|
|
32
|
+
DEPTH/opens-past-75,PASS,scrollDepth threshold 75%
|
|
33
|
+
EXIT/opens,PASS,exitIntent is desktop-only and synthetic leave is approximate
|
|
34
|
+
LATCH/record-abb,PASS,"{""counter"":1,""firstAt"":1791092432503,""lastAt"":1791092432503,""lastID"":""a00fdaff-b"
|
|
35
|
+
LATCH/B-first-wins,PASS,"6/6 as predicted, 0 lost clicks {'abb>abb,aba>none': 6}"
|
|
36
|
+
LATCH/record-aba,PASS,"{""counter"":1,""firstAt"":1791092481637,""lastAt"":1791092481637,""lastID"":""d3745edf-4"
|
|
37
|
+
LATCH/A-first-wins,PASS,"6/6 as predicted, 0 lost clicks {'aba>aba,abb>none': 6}"
|
|
38
|
+
LATCH/order-decides,PASS,"the winner flips with click order, so it is the first EVALUATED, not the first authored"
|
|
39
|
+
FIT390/centre,PASS,scrollW=349/349 childOverflow=0 pageScrollsSideways=False
|
|
40
|
+
FIT390/sheet,PASS,scrollW=373/373 childOverflow=0 pageScrollsSideways=False
|
|
41
|
+
FIT390/drawer,PASS,scrollW=350/350 childOverflow=0 pageScrollsSideways=False
|
|
42
|
+
FIT390/takeover,PASS,scrollW=375/375 childOverflow=0 pageScrollsSideways=False
|
|
43
|
+
FIT390/gate,PASS,scrollW=349/349 childOverflow=0 pageScrollsSideways=False
|
|
44
|
+
FIT390/aba,PASS,scrollW=349/349 childOverflow=0 pageScrollsSideways=False
|
|
45
|
+
FIT768/centre,PASS,scrollW=518/518 childOverflow=0 pageScrollsSideways=False
|
|
46
|
+
FIT768/sheet,PASS,scrollW=718/718 childOverflow=0 pageScrollsSideways=False
|
|
47
|
+
FIT768/drawer,PASS,scrollW=399/399 childOverflow=0 pageScrollsSideways=False
|
|
48
|
+
FIT768/takeover,PASS,scrollW=753/753 childOverflow=0 pageScrollsSideways=False
|
|
49
|
+
FIT768/gate,PASS,scrollW=458/458 childOverflow=0 pageScrollsSideways=False
|
|
50
|
+
FIT768/aba,PASS,scrollW=438/438 childOverflow=0 pageScrollsSideways=False
|
|
@@ -4,6 +4,10 @@ pickOne,"8 reloads of one tab, remember=page",PASS,re-drawn on every load: 6/1/1
|
|
|
4
4
|
pickOne,"8 reloads of one tab, remember=forever",PASS,the same contender all 8 times
|
|
5
5
|
pickOne,"6 fresh browser contexts, remember=forever",PASS,draws again in a new context: 1/3/2 - the record is per browser profile
|
|
6
6
|
pickOne,storage record,PASS,"mos:v1:r:<name> gains lastID, naming the contender the draw chose"
|
|
7
|
+
pickOne,"two buttons, group dl-ab, click B then A",PASS,"B opens, A then does nothing - 6/6 fresh visitors"
|
|
8
|
+
pickOne,"same two buttons, click A then B",PASS,"A opens, B then does nothing - 6/6; the winner flips with the ORDER"
|
|
9
|
+
pickOne,both interactions on ONE button,PASS,the same contender 14/14 - one evaluates first and records itself
|
|
10
|
+
pickOne,what the rule actually does,PASS,"draws among contenders that evaluate TOGETHER; a click delivers one at a time, so click+pickOne is a latch, not a lottery"
|
|
7
11
|
targetModifiers,plain element,PASS,"[{""type"":""all"",""selector"":""._e""}]"
|
|
8
12
|
targetModifiers,plain variant,PASS,"[{""type"":""all"",""selector"":""h2,.m-heading-2""}] - a variant resolves to its own selectors"
|
|
9
13
|
targetModifiers,children(variant),PASS,"appends {""type"":""children"",""selector"":""h2,.m-heading-2""}"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mosaic-headless",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.1",
|
|
4
4
|
"description": "AI-agent skill: build Mosaic Pro (Nextend) WordPress sites by writing the underlying data model directly - 23 custom tables, no visual editor, no DOM. Every node type, style property and node property swept against a live install and asserted on the delivered HTML and compiled CSS. Installs into Claude Code, Cursor, Codex CLI, Gemini CLI, Copilot, Continue, Windsurf and Claude.ai.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wordpress",
|
|
@@ -33,9 +33,23 @@ delivers
|
|
|
33
33
|
</dialog>
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
- **`aria-modal="true"` is emergent, not authored
|
|
37
|
-
modal has an overlay child, because the
|
|
38
|
-
|
|
36
|
+
- **`aria-modal="true"` is emergent, not authored — and you cannot shed it.**
|
|
37
|
+
The render sets it exactly when the modal has an overlay child, because the
|
|
38
|
+
overlay is what actually blocks the page. An earlier version of this page drew
|
|
39
|
+
the obvious conclusion — *no overlay, no `aria-modal`* — and that conclusion is
|
|
40
|
+
**wrong**, because the overlay-less modal is unreachable. A modal committed with
|
|
41
|
+
no `modal-overlay` child comes back from the server with one: `onHeal()` inserts
|
|
42
|
+
it at the end of the child order, and the source says why — *"the overlay is a
|
|
43
|
+
required structural part — non-deletable, one per Modal"*. The synthetic one
|
|
44
|
+
carries no style token and no id, which is the only way to tell it from an
|
|
45
|
+
authored one.
|
|
46
|
+
|
|
47
|
+
Measured on a modal authored with no overlay: the delivered HTML contains
|
|
48
|
+
`<div class="m-modal-overlay" tabindex="-1">`, the host carries
|
|
49
|
+
`aria-modal="true"`, and a real wheel event over the page moves it 0px while the
|
|
50
|
+
same page scrolls 700px with nothing open. **There is no non-blocking modal in
|
|
51
|
+
1.0.9.** A corner notice that must not trap the reader has to be built from
|
|
52
|
+
ordinary nodes, not from `modal`.
|
|
39
53
|
- **`closedby` is `everything` | `clickOutside` | `esc` | `nothing`**, and it is
|
|
40
54
|
emitted as `data-mosaic-modal-closedby`, NOT as the native `closedby` attribute —
|
|
41
55
|
Mosaic opens the host with `show()`, where the native light-dismiss does not
|
|
@@ -85,6 +99,18 @@ at every element of a class.
|
|
|
85
99
|
| `scrollDepth` | timed | the visitor scrolls this far down | `threshold: 50%` |
|
|
86
100
|
| `modal` | timed | the modal opens or closes | — |
|
|
87
101
|
|
|
102
|
+
**`exitIntent` cannot catch someone who has decided to leave.** The gesture it
|
|
103
|
+
detects — pointer travelling up and out through the top — is the same gesture as
|
|
104
|
+
reaching for Back, the tab strip or the address bar. With `awayFor: 600ms` it fires
|
|
105
|
+
*after* the pointer has been gone that long, by which time the visitor is already
|
|
106
|
+
resting on the button, so the modal appears and the navigation takes the whole page
|
|
107
|
+
away a moment later. Measured, after a user reported "it only flashes when I press
|
|
108
|
+
Back": the modal does not close itself and nothing is wrong with it — it opens and
|
|
109
|
+
stays open — the page simply stops existing. So an exit-intent offer reaches the
|
|
110
|
+
visitor whose pointer *drifts* upward without committing, and never the one who has
|
|
111
|
+
already chosen to go. Budget for that when you decide what to put in it, and give
|
|
112
|
+
the modal a manual opener too if anyone ever needs to read it on purpose.
|
|
113
|
+
|
|
88
114
|
They are ordinary interaction types, so they take the envelope the rest of the
|
|
89
115
|
language uses (`references/interactions.md`). The interaction type list is also
|
|
90
116
|
re-grouped in 1.0.9 — Mouse / Page & scroll / Element events — which changes the
|
|
@@ -146,6 +172,36 @@ back in a test without inspecting which modal is open.
|
|
|
146
172
|
|
|
147
173
|
`data/interaction-rules-verification.csv` has the runs.
|
|
148
174
|
|
|
175
|
+
### …and what `pickOne` does on a trigger that is NOT `pageLoad`
|
|
176
|
+
|
|
177
|
+
Run rules apply to any `timed` interaction, and `click` is one, so the obvious
|
|
178
|
+
next question is whether a click-driven `pickOne` is also a lottery. It is not,
|
|
179
|
+
and the difference matters enough to state as a rule:
|
|
180
|
+
|
|
181
|
+
> **`pickOne` draws among the contenders that evaluate TOGETHER.**
|
|
182
|
+
|
|
183
|
+
`pageLoad` schedules every member of the group in the same pass, so the draw has
|
|
184
|
+
a real field and a real winner. A click delivers one contender at a time: the
|
|
185
|
+
first one to evaluate records itself and every other member of the group is then
|
|
186
|
+
suppressed for as long as `remember` holds.
|
|
187
|
+
|
|
188
|
+
Measured with two buttons, one modal each, both carrying the group `dl-ab`, six
|
|
189
|
+
fresh visitors each way:
|
|
190
|
+
|
|
191
|
+
| order clicked | result |
|
|
192
|
+
|---|---|
|
|
193
|
+
| B then A | B opens, then A does nothing — 6/6 |
|
|
194
|
+
| A then B | A opens, then B does nothing — 6/6 |
|
|
195
|
+
|
|
196
|
+
The winner flips with the click order, so it is the first *evaluated*, not the
|
|
197
|
+
first authored. An earlier attempt with both interactions on ONE button drew the
|
|
198
|
+
same contender 14 times out of 14, which is the same fact seen from a worse angle.
|
|
199
|
+
|
|
200
|
+
So click + `pickOne` is a **latch**, not an A/B test: *whichever of these the
|
|
201
|
+
visitor takes first, the others stop offering.* That is genuinely useful — one of
|
|
202
|
+
several calls to action, a first-choice-wins offer — but if you want to split an
|
|
203
|
+
audience, the rule has to sit on `pageLoad`.
|
|
204
|
+
|
|
149
205
|
## Memory: remember / forget
|
|
150
206
|
|
|
151
207
|
Two actions that write and clear the same records a run rule reads, so one
|
|
@@ -330,3 +386,49 @@ The pattern is worth stating on its own: because the trigger lives in the modal'
|
|
|
330
386
|
own `interactionShorthand`, a page's popup policy is one object in that page's data.
|
|
331
387
|
Nothing else on the page changes, no trigger element is placed, and two pages of the
|
|
332
388
|
same site can behave completely differently without sharing anything.
|
|
389
|
+
|
|
390
|
+
## Five forms out of three nodes — and the one property you must not set
|
|
391
|
+
|
|
392
|
+
"A modal" in most builders means one centred card, because the builder owns the
|
|
393
|
+
chrome. Mosaic owns none of it: `.m-modal` is a `position:fixed; inset:0` layer
|
|
394
|
+
with `pointer-events:none`, `.m-modal-overlay` is a second fixed layer that takes
|
|
395
|
+
pointer events back, and `.m-modal-window` is an ordinary child of the first. The
|
|
396
|
+
form is decided by where the window is pinned inside that layer.
|
|
397
|
+
|
|
398
|
+
**Never set `display` on the `modal` host.** A `<dialog>`'s open/closed visibility
|
|
399
|
+
*is* its display property — the UA stylesheet hides it with
|
|
400
|
+
`dialog:not([open]) { display: none }` — so an author-level `display:flex` on the
|
|
401
|
+
host wins the cascade and every modal on the page is permanently on screen. It is
|
|
402
|
+
a tempting thing to write, because flex centring is the obvious way to place the
|
|
403
|
+
window, and it fails in a way that no open-state check can see: a page built this
|
|
404
|
+
way passed 36 of 36 modal checks while all ten of its dialogs sat open in the
|
|
405
|
+
document. Lay the forms out by positioning the WINDOW instead, which leaves
|
|
406
|
+
`display` alone:
|
|
407
|
+
|
|
408
|
+
| form | host | window (`position:absolute`) |
|
|
409
|
+
|---|---|---|
|
|
410
|
+
| centred card | padding only | `top:50%; left:50%` + `translateX(-50%) translateY(-50%)`, `max-width` |
|
|
411
|
+
| bottom sheet | no side/bottom padding | `left:0; right:0; bottom:0`, `margin-inline:auto`, `max-width` |
|
|
412
|
+
| side drawer | no top/bottom/right padding | `top:0; right:0; bottom:0`, `max-width` |
|
|
413
|
+
| corner notice | no padding | `top:90px; right:24px`, a small `max-width` |
|
|
414
|
+
| full takeover | no padding | `top:0; right:0; bottom:0; left:0`, `max-width:none` |
|
|
415
|
+
|
|
416
|
+
`transform` is a structured value, so the centring pull-back is two entries
|
|
417
|
+
(`translateX`, `translateY`), not a string — `references/styling.md` has the shape.
|
|
418
|
+
|
|
419
|
+
All five were built on one page and driven in a browser — 49 checks in
|
|
420
|
+
`data/dialog-form-verification.csv`, written by `tools/verify_dialog_lab.py`,
|
|
421
|
+
whose first assertion is now that a CLOSED modal has no box at all. Three more
|
|
422
|
+
things that only showed up by building them:
|
|
423
|
+
|
|
424
|
+
- **Measure the window against the LAYER, not the viewport.** `.m-modal` is inset
|
|
425
|
+
into the viewport *minus the scrollbar*, so a drawer flush against the right
|
|
426
|
+
edge sits at `innerWidth - 15` and a naive check calls it a 15px gap. Compare
|
|
427
|
+
the window's rect to the host's rect.
|
|
428
|
+
- **Give `modal-window` `overflow: auto` and `max-height: 100%`.** Mosaic has no
|
|
429
|
+
`overflowX`/`overflowY` — only the shorthand — and without it a tall dialog
|
|
430
|
+
grows past the viewport instead of scrolling inside itself.
|
|
431
|
+
- **A page that passes a width check says nothing about its modals.** They are a
|
|
432
|
+
layer that only exists once something opens them, so the document underneath can
|
|
433
|
+
measure clean at 390px while a takeover with desktop padding overflows. The
|
|
434
|
+
verifier re-opens every form at each `--viewports` width for this reason.
|
|
@@ -318,3 +318,29 @@ everyone remembers.
|
|
|
318
318
|
The general shape, worth carrying beyond this repo: a derived artifact checked into
|
|
319
319
|
the tree is only as fresh as the last time someone re-derived it, and if the deriving
|
|
320
320
|
input is not in the tree, no amount of CI will notice.
|
|
321
|
+
|
|
322
|
+
## Setting `display` on a `<dialog>` pins it open, and open-state checks cannot see it
|
|
323
|
+
|
|
324
|
+
The modal host is a `<dialog>`. Its open/closed visibility is not a class or an
|
|
325
|
+
attribute Mosaic manages — it is the UA stylesheet rule
|
|
326
|
+
`dialog:not([open]) { display: none }`. Any author-level `display` on that element
|
|
327
|
+
wins the cascade, and every modal in the document is then on screen permanently,
|
|
328
|
+
stacked over the page in DOM order.
|
|
329
|
+
|
|
330
|
+
`display:flex` on the host is exactly what you reach for when you want to centre
|
|
331
|
+
the window, so this is not an exotic mistake. What makes it worth its own section
|
|
332
|
+
is that **it survives a thorough test suite**: 36 checks drove the modals' triggers,
|
|
333
|
+
run rules, dismissal policies and geometry, every one of them PASSED, and the page
|
|
334
|
+
they passed against had all ten of its dialogs open at once. Every check asked
|
|
335
|
+
"when this is open, is it right?" — none asked "when it is closed, is it gone?"
|
|
336
|
+
|
|
337
|
+
Two rules come out of it:
|
|
338
|
+
|
|
339
|
+
- **Lay a modal's form out by positioning the window** (`position:absolute` plus
|
|
340
|
+
edges inside the fixed, `inset:0` host), never by making the host a flex or grid
|
|
341
|
+
container.
|
|
342
|
+
- **Assert the closed state.** `tools/verify_dialog_lab.py` now closes everything
|
|
343
|
+
first and fails if any `dialog:not([open])` still has a box. The general form of
|
|
344
|
+
that lesson: for anything with a visible and an invisible state, a suite that
|
|
345
|
+
only exercises the visible one is testing half a component, and the half it
|
|
346
|
+
skips is the one the visitor sees most of the time.
|
package/references/styling.md
CHANGED
|
@@ -184,3 +184,32 @@ down — at the cost of bypassing the collection-variable layer entirely.
|
|
|
184
184
|
|
|
185
185
|
`tools/build_page.py` wraps the five shapes above as `radius`, `shadow`,
|
|
186
186
|
`transitionAll`, `move` and `border` shorthands so a design spec cannot get them wrong.
|
|
187
|
+
|
|
188
|
+
## Properties that exist in CSS but not in Mosaic
|
|
189
|
+
|
|
190
|
+
`data/style-properties.csv` is the whole registry, and the fastest way to lose an
|
|
191
|
+
hour is to assume a property is in it because CSS has it. Two that catch people,
|
|
192
|
+
both found by writing a page rather than by reading the table:
|
|
193
|
+
|
|
194
|
+
**`flexGrow` and `flexShrink` do not exist.** Mosaic models both as one
|
|
195
|
+
type-switched group, `flexSizing`:
|
|
196
|
+
|
|
197
|
+
```jsonc
|
|
198
|
+
"flexSizing": {"type": "grow"} // flex-grow:1; flex-shrink:0
|
|
199
|
+
"flexSizing": {"type": "shrink"}
|
|
200
|
+
"flexSizing": {"type": "none"} // the fixed-width column in a flex row
|
|
201
|
+
"flexSizing": {"type": "custom", "customOptions": {…}}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`none` is what you want for a label column that must not be squeezed, and `grow`
|
|
205
|
+
for the cell that takes the slack — the pair that `flex-shrink:0` / `flex-grow:1`
|
|
206
|
+
would have been.
|
|
207
|
+
|
|
208
|
+
**There is no `overflowX` or `overflowY`** — only the shorthand `overflow`, with
|
|
209
|
+
`visible | hidden | clip | scroll | auto`. One axis at a time is not expressible
|
|
210
|
+
through the style system; it needs `customDeclarations`.
|
|
211
|
+
|
|
212
|
+
Both of these are refused at build time by `build_page.py` rather than silently
|
|
213
|
+
dropped, which is the only reason they cost minutes instead of a debugging session
|
|
214
|
+
— a property that is not in the registry cannot be set at all, and Mosaic's own
|
|
215
|
+
API accepts the write and discards it.
|
|
@@ -102,7 +102,8 @@ const counts = {
|
|
|
102
102
|
"data/slider-verification.csv": 19,
|
|
103
103
|
"data/navigation-verification.csv": 46,
|
|
104
104
|
"data/page-modal-verification.csv": 19,
|
|
105
|
-
"data/interaction-rules-verification.csv":
|
|
105
|
+
"data/interaction-rules-verification.csv": 17,
|
|
106
|
+
"data/dialog-form-verification.csv": 49,
|
|
106
107
|
"data/value-coercion-verification.csv": 12,
|
|
107
108
|
};
|
|
108
109
|
for (const [file, expected] of Object.entries(counts)) {
|
package/sites/_moksa.py
CHANGED
|
@@ -26,6 +26,7 @@ Run from this directory: python _moksa.py
|
|
|
26
26
|
"""
|
|
27
27
|
import json
|
|
28
28
|
import os
|
|
29
|
+
import sys
|
|
29
30
|
import uuid
|
|
30
31
|
|
|
31
32
|
# Monospace leads. The grotesque is the display face and appears at three sizes only;
|
|
@@ -247,7 +248,8 @@ def mask_line(attr, text, tag="h1", **st):
|
|
|
247
248
|
|
|
248
249
|
# ── content, all of it Moksa Web's own ───────────────────────────────────────
|
|
249
250
|
NAV = [("服務", "#services"), ("作品", "#works"),
|
|
250
|
-
("產品", "#products"), ("
|
|
251
|
+
("產品", "#products"), ("彈窗", "/dialogs/"),
|
|
252
|
+
("聯絡", "#contact")]
|
|
251
253
|
|
|
252
254
|
# key / figure / caption, read as a datasheet rather than as four big numbers
|
|
253
255
|
SPEC = [
|
|
@@ -3050,6 +3052,15 @@ def clean(node):
|
|
|
3050
3052
|
|
|
3051
3053
|
if __name__ == "__main__":
|
|
3052
3054
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
3055
|
+
sys.path.insert(0, here)
|
|
3056
|
+
# Deferred on purpose. `_dialogs` imports this module's helpers, so importing
|
|
3057
|
+
# it at the top would be circular; down here the file re-imports cleanly as
|
|
3058
|
+
# `_moksa` (this copy is `__main__`), its own main block does not run, and the
|
|
3059
|
+
# helpers it reads are pure functions - two copies produce identical dicts.
|
|
3060
|
+
import _dialogs
|
|
3061
|
+
SITE["pages"].append({"slug": "dialogs", "post_id": 268,
|
|
3062
|
+
"title": "彈窗實驗室",
|
|
3063
|
+
"tree": apply_type(_dialogs.TREE, DISPLAY, CJK, "600")})
|
|
3053
3064
|
path = os.path.join(here, "moksa.json")
|
|
3054
3065
|
with open(path, "w", encoding="utf-8") as fh:
|
|
3055
3066
|
json.dump(clean(SITE), fh, indent=1, ensure_ascii=False)
|