mosaic-headless 1.21.2 → 1.23.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/SKILL.md +10 -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 +18 -0
- package/data/value-coercion-verification.csv +13 -0
- package/package.json +1 -1
- package/references/dialog-and-triggers.md +120 -3
- package/references/failure-modes.md +50 -0
- package/references/interactions.md +33 -0
- package/references/styling.md +29 -0
- package/references/write-protocol.md +31 -0
- package/scripts/check-release.mjs +3 -0
- package/sites/_moksa.py +12 -1
- package/sites/moksa.json +12823 -2
- package/tools/build_page.py +20 -0
- package/tools/check_tables.py +81 -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.0"
|
|
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,11 +808,13 @@ 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 |
|
|
813
815
|
| `bootstrap_probe_theme.php` | a licence-free scratch theme |
|
|
814
816
|
| `mint_session.php` | a matching cookie + `wp_rest` nonce from WP-CLI |
|
|
817
|
+
| `check_tables.py` | re-extract all eleven source-derived tables and diff them against the shipped ones - the check for a table that is not wrong but STALE, which nothing else can see |
|
|
815
818
|
|
|
816
819
|
## Regenerating everything
|
|
817
820
|
|
|
@@ -823,6 +826,12 @@ python tools/extract_default_children.py <plugin-root> data/
|
|
|
823
826
|
python tools/extract_style_properties.py <plugin-root> data/
|
|
824
827
|
python tools/extract_interactions.py <plugin-root> data/
|
|
825
828
|
python tools/extract_dynamic_variables.py <plugin-root> data/
|
|
829
|
+
|
|
830
|
+
# and then, always: a table extracted BEFORE its extractor was repaired keeps
|
|
831
|
+
# describing the old source and ships silently. Row counts cannot see it and the
|
|
832
|
+
# release gate never sees the plugin. Run this after ANY change to an extractor,
|
|
833
|
+
# not only after a plugin upgrade.
|
|
834
|
+
python tools/check_tables.py <plugin-root>
|
|
826
835
|
python tools/capture_live.py data/ # from data/raw/*.json + db-columns.txt
|
|
827
836
|
# (raw dumps are gitignored;
|
|
828
837
|
# re-capture from a live site)
|
|
@@ -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.0"
|
|
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.0"
|
|
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.0"
|
|
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.0"
|
|
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.0"
|
|
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"":1791091416059,""lastAt"":1791091416059,""lastID"":""2c17cec6-3"
|
|
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"":1791091466323,""lastAt"":1791091466323,""lastID"":""031d462e-7"
|
|
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
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
subject,case,result,detail
|
|
2
|
+
pickOne,"12 fresh loads, remember=page",PASS,exactly one of the three modals opened every time; 4/6/2 across the group
|
|
3
|
+
pickOne,"8 reloads of one tab, remember=page",PASS,re-drawn on every load: 6/1/1
|
|
4
|
+
pickOne,"8 reloads of one tab, remember=forever",PASS,the same contender all 8 times
|
|
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
|
+
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"
|
|
11
|
+
targetModifiers,plain element,PASS,"[{""type"":""all"",""selector"":""._e""}]"
|
|
12
|
+
targetModifiers,plain variant,PASS,"[{""type"":""all"",""selector"":""h2,.m-heading-2""}] - a variant resolves to its own selectors"
|
|
13
|
+
targetModifiers,children(variant),PASS,"appends {""type"":""children"",""selector"":""h2,.m-heading-2""}"
|
|
14
|
+
targetModifiers,sibling(variant),PASS,"appends {""type"":""sibling"",...}"
|
|
15
|
+
targetModifiers,parent(variant),PASS,"appends {""type"":""parent"",...}"
|
|
16
|
+
targetModifiers,include(element),PASS,"appends {""type"":""include"",...}"
|
|
17
|
+
targetModifiers,exclude(element),PASS,"appends {""type"":""exclude"",...}"
|
|
18
|
+
targetModifiers,children then exclude,PASS,"three entries, in the order written - the list is a pipeline, not a filter set"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
validator,probed_on,sent,result,note
|
|
2
|
+
ValidatorInteger,maxlength on textarea-input,"""12""",12,accepted
|
|
3
|
+
ValidatorInteger,maxlength on textarea-input,"""12abc""",12,"trailing garbage ignored - parseInt semantics, new in 1.0.9"
|
|
4
|
+
ValidatorInteger,maxlength on textarea-input,"""abc""",(absent),"no leading digits, rejected"
|
|
5
|
+
ValidatorInteger,maxlength on textarea-input,"""-5x""",(absent),"parses to -5, then refused by the property's own min:0 - the parser accepted it"
|
|
6
|
+
ValidatorInteger,maxlength on textarea-input,""" 7 """,7,leading and trailing whitespace tolerated
|
|
7
|
+
ValidatorInteger,maxlength on textarea-input,"""0012""",12,"leading zeros are decimal, not octal"
|
|
8
|
+
ValidatorInteger,maxlength on textarea-input,"""1e3""",1,NOT 1000 - the exponent is trailing garbage
|
|
9
|
+
ValidatorInteger,maxlength on textarea-input,"""12.9""",12,"truncated toward zero, not rounded"
|
|
10
|
+
stretch,width,"""stretch""",width:-webkit-fill-available then width:stretch,"both emitted, prefix first"
|
|
11
|
+
stretch,height,"""stretch""",height:-webkit-fill-available then height:stretch,both emitted
|
|
12
|
+
stretch,maxWidth,"""stretch""",max-width:-webkit-fill-available then max-width:stretch,both emitted
|
|
13
|
+
stretch,width,"""STRETCH""",width:STRETCH,"kept as typed - the match is case-sensitive, and CSS keywords are not, so it still applies"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mosaic-headless",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
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
|
|
@@ -119,6 +133,63 @@ What that writes, measured: `localStorage` **and** `sessionStorage`, both under
|
|
|
119
133
|
that opens a second tab to prove a cap is testing nothing — reload the same tab.
|
|
120
134
|
That mistake made the cap look broken here until the tab was reused.
|
|
121
135
|
|
|
136
|
+
### `pickOne`, measured
|
|
137
|
+
|
|
138
|
+
`pickOne` is the one rule that is about a GROUP rather than one interaction, and the
|
|
139
|
+
group is the `name`: give three modals the same `name` and exactly one of them opens.
|
|
140
|
+
Three `pageLoad` modals sharing `probe-pick` were loaded repeatedly:
|
|
141
|
+
|
|
142
|
+
| what | result |
|
|
143
|
+
|---|---|
|
|
144
|
+
| 12 fresh browser contexts, `remember: "page"` | one modal every time, 4 / 6 / 2 across the three |
|
|
145
|
+
| 8 reloads of the SAME tab, `remember: "page"` | re-drawn each load — 6 / 1 / 1 |
|
|
146
|
+
| 8 reloads of the same tab, `remember: "forever"` | the same contender all 8 times |
|
|
147
|
+
| 6 fresh contexts, `remember: "forever"` | draws again — 1 / 3 / 2 |
|
|
148
|
+
|
|
149
|
+
No load ever opened more than one. So `remember` on `pickOne` does not mean "how long
|
|
150
|
+
until it may run again", it means **how long the draw is kept**: `page` re-rolls on
|
|
151
|
+
every navigation, `session` and `forever` hand one visitor the same variant each time
|
|
152
|
+
— which is the whole point, since a popup that changes on reload reads as a bug. The
|
|
153
|
+
last row is the limit worth knowing: `forever` is localStorage, so it is per browser
|
|
154
|
+
profile, and a fresh context is a fresh visitor who draws again.
|
|
155
|
+
|
|
156
|
+
The record gains one field for this: alongside `counter` / `firstAt` / `lastAt`,
|
|
157
|
+
`mos:v1:r:<name>` carries **`lastID`**, the uuid of the interaction that won the draw.
|
|
158
|
+
That is what makes the choice stick, and it is also how you read the drawn variant
|
|
159
|
+
back in a test without inspecting which modal is open.
|
|
160
|
+
|
|
161
|
+
`data/interaction-rules-verification.csv` has the runs.
|
|
162
|
+
|
|
163
|
+
### …and what `pickOne` does on a trigger that is NOT `pageLoad`
|
|
164
|
+
|
|
165
|
+
Run rules apply to any `timed` interaction, and `click` is one, so the obvious
|
|
166
|
+
next question is whether a click-driven `pickOne` is also a lottery. It is not,
|
|
167
|
+
and the difference matters enough to state as a rule:
|
|
168
|
+
|
|
169
|
+
> **`pickOne` draws among the contenders that evaluate TOGETHER.**
|
|
170
|
+
|
|
171
|
+
`pageLoad` schedules every member of the group in the same pass, so the draw has
|
|
172
|
+
a real field and a real winner. A click delivers one contender at a time: the
|
|
173
|
+
first one to evaluate records itself and every other member of the group is then
|
|
174
|
+
suppressed for as long as `remember` holds.
|
|
175
|
+
|
|
176
|
+
Measured with two buttons, one modal each, both carrying the group `dl-ab`, six
|
|
177
|
+
fresh visitors each way:
|
|
178
|
+
|
|
179
|
+
| order clicked | result |
|
|
180
|
+
|---|---|
|
|
181
|
+
| B then A | B opens, then A does nothing — 6/6 |
|
|
182
|
+
| A then B | A opens, then B does nothing — 6/6 |
|
|
183
|
+
|
|
184
|
+
The winner flips with the click order, so it is the first *evaluated*, not the
|
|
185
|
+
first authored. An earlier attempt with both interactions on ONE button drew the
|
|
186
|
+
same contender 14 times out of 14, which is the same fact seen from a worse angle.
|
|
187
|
+
|
|
188
|
+
So click + `pickOne` is a **latch**, not an A/B test: *whichever of these the
|
|
189
|
+
visitor takes first, the others stop offering.* That is genuinely useful — one of
|
|
190
|
+
several calls to action, a first-choice-wins offer — but if you want to split an
|
|
191
|
+
audience, the rule has to sit on `pageLoad`.
|
|
192
|
+
|
|
122
193
|
## Memory: remember / forget
|
|
123
194
|
|
|
124
195
|
Two actions that write and clear the same records a run rule reads, so one
|
|
@@ -303,3 +374,49 @@ The pattern is worth stating on its own: because the trigger lives in the modal'
|
|
|
303
374
|
own `interactionShorthand`, a page's popup policy is one object in that page's data.
|
|
304
375
|
Nothing else on the page changes, no trigger element is placed, and two pages of the
|
|
305
376
|
same site can behave completely differently without sharing anything.
|
|
377
|
+
|
|
378
|
+
## Five forms out of three nodes — and the one property you must not set
|
|
379
|
+
|
|
380
|
+
"A modal" in most builders means one centred card, because the builder owns the
|
|
381
|
+
chrome. Mosaic owns none of it: `.m-modal` is a `position:fixed; inset:0` layer
|
|
382
|
+
with `pointer-events:none`, `.m-modal-overlay` is a second fixed layer that takes
|
|
383
|
+
pointer events back, and `.m-modal-window` is an ordinary child of the first. The
|
|
384
|
+
form is decided by where the window is pinned inside that layer.
|
|
385
|
+
|
|
386
|
+
**Never set `display` on the `modal` host.** A `<dialog>`'s open/closed visibility
|
|
387
|
+
*is* its display property — the UA stylesheet hides it with
|
|
388
|
+
`dialog:not([open]) { display: none }` — so an author-level `display:flex` on the
|
|
389
|
+
host wins the cascade and every modal on the page is permanently on screen. It is
|
|
390
|
+
a tempting thing to write, because flex centring is the obvious way to place the
|
|
391
|
+
window, and it fails in a way that no open-state check can see: a page built this
|
|
392
|
+
way passed 36 of 36 modal checks while all ten of its dialogs sat open in the
|
|
393
|
+
document. Lay the forms out by positioning the WINDOW instead, which leaves
|
|
394
|
+
`display` alone:
|
|
395
|
+
|
|
396
|
+
| form | host | window (`position:absolute`) |
|
|
397
|
+
|---|---|---|
|
|
398
|
+
| centred card | padding only | `top:50%; left:50%` + `translateX(-50%) translateY(-50%)`, `max-width` |
|
|
399
|
+
| bottom sheet | no side/bottom padding | `left:0; right:0; bottom:0`, `margin-inline:auto`, `max-width` |
|
|
400
|
+
| side drawer | no top/bottom/right padding | `top:0; right:0; bottom:0`, `max-width` |
|
|
401
|
+
| corner notice | no padding | `top:90px; right:24px`, a small `max-width` |
|
|
402
|
+
| full takeover | no padding | `top:0; right:0; bottom:0; left:0`, `max-width:none` |
|
|
403
|
+
|
|
404
|
+
`transform` is a structured value, so the centring pull-back is two entries
|
|
405
|
+
(`translateX`, `translateY`), not a string — `references/styling.md` has the shape.
|
|
406
|
+
|
|
407
|
+
All five were built on one page and driven in a browser — 49 checks in
|
|
408
|
+
`data/dialog-form-verification.csv`, written by `tools/verify_dialog_lab.py`,
|
|
409
|
+
whose first assertion is now that a CLOSED modal has no box at all. Three more
|
|
410
|
+
things that only showed up by building them:
|
|
411
|
+
|
|
412
|
+
- **Measure the window against the LAYER, not the viewport.** `.m-modal` is inset
|
|
413
|
+
into the viewport *minus the scrollbar*, so a drawer flush against the right
|
|
414
|
+
edge sits at `innerWidth - 15` and a naive check calls it a 15px gap. Compare
|
|
415
|
+
the window's rect to the host's rect.
|
|
416
|
+
- **Give `modal-window` `overflow: auto` and `max-height: 100%`.** Mosaic has no
|
|
417
|
+
`overflowX`/`overflowY` — only the shorthand — and without it a tall dialog
|
|
418
|
+
grows past the viewport instead of scrolling inside itself.
|
|
419
|
+
- **A page that passes a width check says nothing about its modals.** They are a
|
|
420
|
+
layer that only exists once something opens them, so the document underneath can
|
|
421
|
+
measure clean at 390px while a takeover with desktop padding overflows. The
|
|
422
|
+
verifier re-opens every form at each `--viewports` width for this reason.
|
|
@@ -294,3 +294,53 @@ The other half of this finding is about the checkers: WordPress's "critical erro
|
|
|
294
294
|
page is 2,697 bytes, above the 2,000-byte healthy floor, and `Client.page()` was
|
|
295
295
|
returning an HTTPError's body as the page - so `build_site` announced `OK` over a
|
|
296
296
|
500 and a whole batch believed it. A 5xx now returns an empty body.
|
|
297
|
+
|
|
298
|
+
## A shipped table can be stale rather than wrong
|
|
299
|
+
|
|
300
|
+
The failure this skill nearly shipped three times in a row is not a bad value, it is
|
|
301
|
+
an old one. `data/node-types.csv` carried an empty `default_element_class` for three
|
|
302
|
+
releases: the column's regex was repaired AFTER the extraction run that produced the
|
|
303
|
+
table, so the file went on describing the previous source and nothing compared the
|
|
304
|
+
two again.
|
|
305
|
+
|
|
306
|
+
Nothing in place could see it. Row counts were right the whole time — the table had
|
|
307
|
+
the correct number of rows, with one column quietly blank. The release gate counts
|
|
308
|
+
verification tables but never sees the plugin source, because that source is licensed
|
|
309
|
+
third-party code and is not in the repository, so it cannot re-derive anything. And a
|
|
310
|
+
reader of the skill has no way to tell a legitimately-empty column from a lost one.
|
|
311
|
+
|
|
312
|
+
`tools/check_tables.py` closes it: it re-runs all seven extractors into a tempdir and
|
|
313
|
+
diffs every output against the shipped file, naming the first differing line rather
|
|
314
|
+
than just the filename. The moment to run it is **after any change to a
|
|
315
|
+
`tools/extract_*.py`**, not only after a plugin upgrade — the upgrade is the case
|
|
316
|
+
everyone remembers.
|
|
317
|
+
|
|
318
|
+
The general shape, worth carrying beyond this repo: a derived artifact checked into
|
|
319
|
+
the tree is only as fresh as the last time someone re-derived it, and if the deriving
|
|
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.
|
|
@@ -143,6 +143,39 @@ Mosaic also *added* a `uuid` of its own. So on this data model:
|
|
|
143
143
|
|
|
144
144
|
That check is worth applying to any deeply-nested Mosaic write, not just interactions.
|
|
145
145
|
|
|
146
|
+
## Aiming an action somewhere else: `targetModifiers`
|
|
147
|
+
|
|
148
|
+
Every pluggable action takes a `settings.target`, and a target is either
|
|
149
|
+
`{"type": "element", "uuid": "<nodeID>"}` or `{"type": "variant", "uuid": "<variantID>"}`.
|
|
150
|
+
`settings.targetModifiers` then narrows or widens it — a list of
|
|
151
|
+
`{"uuid", "type", "data"}` where `data` is another target of the same two shapes and
|
|
152
|
+
`type` is one of `children`, `sibling`, `parent`, `include`, `exclude`.
|
|
153
|
+
|
|
154
|
+
The thing worth knowing is what that compiles to. The page hands the runtime a
|
|
155
|
+
`targetSelectorData` **pipeline**: one entry for the base target, then one per
|
|
156
|
+
modifier, applied in the order written.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
plain element [{"type":"all","selector":"._e"}]
|
|
160
|
+
plain variant [{"type":"all","selector":"h2,.m-heading-2"}]
|
|
161
|
+
element + children(variant) [{"type":"all","selector":"._e"},
|
|
162
|
+
{"type":"children","selector":"h2,.m-heading-2"}]
|
|
163
|
+
element + sibling(variant) [... {"type":"sibling", ...}]
|
|
164
|
+
element + parent(variant) [... {"type":"parent", ...}]
|
|
165
|
+
variant + include(element) [... {"type":"include", ...}]
|
|
166
|
+
variant + exclude(element) [... {"type":"exclude", ...}]
|
|
167
|
+
children then exclude three entries, in that order
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Two consequences. A **variant target resolves to the variant's own CSS selectors**,
|
|
171
|
+
so "every H2" costs you nothing — you do not have to enumerate nodes. And because it
|
|
172
|
+
is a pipeline rather than a filter set, **order matters**: `children` then `exclude`
|
|
173
|
+
excludes from the children, while the reverse excludes from the base and then
|
|
174
|
+
descends. Write the modifiers in the order you want them applied.
|
|
175
|
+
|
|
176
|
+
All eight shapes above were committed and read back from the delivered page;
|
|
177
|
+
`data/interaction-rules-verification.csv` has them.
|
|
178
|
+
|
|
146
179
|
## The surface, extracted
|
|
147
180
|
|
|
148
181
|
`data/interaction-types.csv` — 12 trigger types in two families:
|
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.
|
|
@@ -77,3 +77,34 @@ A failed validator does not return an HTTP error. The response is a
|
|
|
77
77
|
`RESTJSONExceptionEnvelope`: HTTP 200 with an `exceptions` array in the body. Code
|
|
78
78
|
that only checks the status code will report a clean save on a rejected write.
|
|
79
79
|
Always read `exceptions` before believing a commit landed.
|
|
80
|
+
|
|
81
|
+
## Validators coerce before they reject — a number need not arrive as a number
|
|
82
|
+
|
|
83
|
+
Silent rejection is the documented hazard, but the quieter one is silent *acceptance*
|
|
84
|
+
of something other than what you sent. As of 1.0.9 `ValidatorInteger` mirrors
|
|
85
|
+
JavaScript's `parseInt()`: it takes the leading integer and discards the rest, where
|
|
86
|
+
the previous release fell back to PHP's `intval()` and treated a zero result as a
|
|
87
|
+
failure. Measured on `textarea-input`'s `maxlength`:
|
|
88
|
+
|
|
89
|
+
| sent | stored | |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `"12"` | 12 | |
|
|
92
|
+
| `" 7 "` | 7 | whitespace tolerated |
|
|
93
|
+
| `"0012"` | 12 | decimal, not octal |
|
|
94
|
+
| `"12abc"` | **12** | trailing garbage ignored |
|
|
95
|
+
| `"12.9"` | **12** | truncated toward zero, not rounded |
|
|
96
|
+
| `"1e3"` | **1** | **not** 1000 — the exponent is trailing garbage |
|
|
97
|
+
| `"abc"` | absent | no leading digits |
|
|
98
|
+
| `"-5x"` | absent | parsed fine as −5; refused by the property's own `min: 0` |
|
|
99
|
+
|
|
100
|
+
`"1e3"` is the row to remember: a value that looks numeric to a human, is numeric to
|
|
101
|
+
`floatval()`, and lands as 1. A typo in a unit (`"16px"` where a bare integer was
|
|
102
|
+
wanted) likewise stores 16 and never complains. Send integers as integers, and read
|
|
103
|
+
the row back when you did not.
|
|
104
|
+
|
|
105
|
+
The companion change is `ValidatorMimeType`, which now accepts any number of media
|
|
106
|
+
type parameters including quoted values containing spaces — so
|
|
107
|
+
`video/mp4; codecs="avc1.42E01E, mp4a.40.2"` is valid where 1.0.8 rejected it.
|
|
108
|
+
|
|
109
|
+
`data/value-coercion-verification.csv` has these runs, along with the `stretch`
|
|
110
|
+
keyword's emitted declarations.
|
|
@@ -102,6 +102,9 @@ 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": 17,
|
|
106
|
+
"data/dialog-form-verification.csv": 49,
|
|
107
|
+
"data/value-coercion-verification.csv": 12,
|
|
105
108
|
};
|
|
106
109
|
for (const [file, expected] of Object.entries(counts)) {
|
|
107
110
|
if (!fs.existsSync(path.join(ROOT, file))) { fail(`${file} missing`); continue; }
|
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)
|