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 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.21.2"
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.21.2"
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.21.2"
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.21.2"
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.21.2"
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.21.2"
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.21.2",
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.** It appears exactly when the
37
- modal has an overlay child, because the overlay is what actually blocks the page.
38
- No overlay, no `aria-modal`.
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:
@@ -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"), ("聯絡", "#contact")]
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)