mosaic-headless 1.2.1 → 1.4.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.
@@ -193,6 +193,58 @@ disagree, you are not looking at what you wrote.
193
193
  document that was actually committed. Do the same in anything else that checks a page
194
194
  - including a browser: hard-reload is not always enough, a query string always is.
195
195
 
196
+ ## Changing a design token's VALUE leaves the old one in `:root`
197
+
198
+ A collection variable is a row, and a row is identified by its ID. If the writer
199
+ mints a fresh ID each run - which `build_page.py` did, because its `_varIDs` cache
200
+ starts empty every time - then changing a token's value does not change the token.
201
+ It adds a second one.
202
+
203
+ The rows hang off the theme's collection, and `build_site.py` makes a fresh master
204
+ per build while the collection persists, so nothing ever cleans them up. Both
205
+ declarations reach `:root` and the later one wins:
206
+
207
+ ```css
208
+ :root{ ... --mk-faint: rgb(107, 109, 113); ... --mk-faint: rgb(160, 162, 168); ... }
209
+ ```
210
+
211
+ Measured: after changing `--mk-faint`, a rebuild reported OK, the new variable
212
+ existed with the correct value, `verify_rwd.py` passed, and the page kept rendering
213
+ the old grey. Ten stale rows had accumulated across earlier builds. **Every
214
+ server-side check agreed with the intent and the page still disagreed** - only
215
+ `verify_browser.py`, reading the computed colour off the element, could see it.
216
+
217
+ The fix is that a token's row ID must be a function of its name -
218
+ `uuid5(VAR_NAMESPACE, "--mk-faint")` - so a rebuild rebinds the row instead of
219
+ adding one, plus a reap of anything on the collection claiming a managed custom
220
+ property under an ID we did not derive. Both are in `theme_records()`.
221
+
222
+ The general shape is worth remembering beyond tokens: **anything keyed by a random
223
+ ID that you write repeatedly will accumulate**, and duplicates in a cascade fail
224
+ silently in favour of whichever happens to be last.
225
+
226
+ ## A declaration can be present, correct, and still wrong
227
+
228
+ The fourth failure mode in SKILL.md - "wrong value SHAPE, HTTP 200, and the CSS rule
229
+ is simply absent" - has a quieter sibling: the rule is *present*, the value is what
230
+ you asked for, and what the browser does with it is not what you meant.
231
+
232
+ Three ways, all measured on the example page:
233
+
234
+ - **The unit resolves against something you did not think about.**
235
+ `letter-spacing: -0.035em` is a normal amount of tightening for a Latin display
236
+ face. On a 62px headline it is -2.17px, and the headline was Chinese.
237
+ - **The face cannot render the text.** `font-family: 'Space Grotesk', 'Noto Sans TC'`
238
+ is honoured exactly: Space Grotesk has no CJK coverage, so Han characters come from
239
+ Noto Sans TC - while still carrying the tracking that was chosen for the Latin
240
+ face. Nothing reports a fallback.
241
+ - **The property was never in play.** `display: inline-block` on a flex item is
242
+ blockified by the spec. The declaration was in the stylesheet, correct, and
243
+ computed to `block`.
244
+
245
+ None of these is visible to anything that reads the stylesheet, because the
246
+ stylesheet is right. `verify_browser.py` asks the element instead.
247
+
196
248
  ## Reproducing all of this
197
249
 
198
250
  ```bash
@@ -68,16 +68,61 @@ Confirmed from it:
68
68
  it is a percentage of the scroll range, not milliseconds.
69
69
  - **`easing` is defaulted to `ease`** by the server.
70
70
 
71
- ## What is NOT solved
71
+ ## What is NOT solved - and exactly how far it now goes
72
72
 
73
- **Property binding.** `propertyMetas` and per-keyframe `properties` did not survive
74
- into the payload in any shape tried: flat array of metas, metas nested one level
75
- deeper (which made the whole interaction vanish), properties as an object, properties
76
- as an array. The keyframes arrive with timing but with nothing to animate.
73
+ `tools/sweep_interactions.py` commits candidate shapes to a live page and judges each
74
+ against `var mosaicInteractions` in the delivered HTML **and** against the row read
75
+ back out of the database, with negative controls. Results in
76
+ `data/interaction-verification.csv`.
77
77
 
78
- Do not ship an interaction animation on the strength of this page. Use the CSS path,
79
- or drive the editor once by hand and read the stored row back out of `wp_mosaic_nodes`
80
- — that row is the authoritative example, and one look at it would settle the shape.
78
+ **`propertyMetas` is NOT the problem, and the previous claim here was wrong.** It is
79
+ accepted, stored, and keeps its order:
80
+
81
+ ```json
82
+ "propertyMetas": [{"uuid": "<uuid>", "type": "predefined", "timelineKey": "_",
83
+ "predefinedOptions": {"name": "opacity"}}]
84
+ ```
85
+
86
+ `type` is one of `predefined | custom | static | collectionMode | collectionVariable
87
+ | display`; the options key is that type plus `Options`
88
+ (`KeyframePropertyMetaTypeFactoryAbstract::getPropertyMetaOptionsDataName()`), and
89
+ for `predefined` the `name` is the property itself - the full list is
90
+ `data/animatable-properties.csv`.
91
+
92
+ **`uuid` is optional on create and REQUIRED on update.** `DataArray::initValues`
93
+ mints one when it is missing, but `DataArray::applyCommitData` matches existing items
94
+ by uuid and rebuilds `order` from `array_column($new, 'uuid')` - so on any later
95
+ commit an item without a uuid is not matched, not carried into the new order, and
96
+ disappears. Measured both ways: the same shape stored 1 meta on create and 0 after an
97
+ update.
98
+
99
+ **What still does not bind is `initial` and the keyframes' `properties`.** Four
100
+ attempts, each with the row read back:
101
+
102
+ | attempt | result |
103
+ |---|---|
104
+ | one commit carrying metas + values | metas stored, 0 property values |
105
+ | second commit adding the values | metas stored, 0 property values |
106
+ | second commit that also REORDERS the metas, so `propertyMetas/order` genuinely changes | reorder took effect in storage, still 0 property values |
107
+ | the values written **straight into `wp_mosaic_nodes`**, all caches flushed | still absent from the payload |
108
+
109
+ That last row is the informative one: it rules out the trust model, the commit
110
+ validators and the cache. The gate is in the export itself -
111
+ `KeyframePropertiesDataSub::exportForInteraction()` will only emit a property for
112
+ which `$this->hasSubData($name)` is true, and those descriptors exist only where
113
+ `AnimationActionOptionsDataSubAbstract::syncAttachedPropertyMetas()` has created
114
+ them, on a shared `DataMeta` that starts empty every time the object is constructed.
115
+
116
+ So the honest position is: **the envelope, the trigger, the timeline and the property
117
+ metas can all be written headlessly; the property VALUES have not been.** Use the CSS
118
+ path, which is fully verified - see `data/style-state-verification.csv`, where 36 of
119
+ 37 probed states compiled to exactly the selector the table promises.
120
+
121
+ The one experiment left, and it is cheap: author a single interaction in the editor,
122
+ then `SELECT data FROM wp_mosaic_nodes` for that node. One look settles it.
123
+
124
+ **`pointerEnter` produced no `mosaicInteractions` entry at all**, so the timed family
125
+ needs something the progress family does not - also unfinished.
81
126
 
82
127
  ## The failure mode that makes this hard
83
128
 
package/sites/_moksa.py CHANGED
@@ -41,9 +41,10 @@ TOKENS = {
41
41
  "--mk-paper": {"type": "color", "value": "rgb(250,250,247)"},
42
42
  "--mk-panel": {"type": "color", "value": "rgb(243,243,239)"},
43
43
  "--mk-accent": {"type": "color", "value": "rgb(255,90,54)"},
44
- "--mk-muted": {"type": "color", "value": "rgb(111,113,120)"},
44
+ "--mk-accent-ink": {"type": "color", "value": "rgb(191,68,40)"},
45
+ "--mk-muted": {"type": "color", "value": "rgb(90,92,97)"},
45
46
  "--mk-rule": {"type": "color", "value": "rgb(214,214,206)"},
46
- "--mk-faint": {"type": "color", "value": "rgb(160,162,168)"},
47
+ "--mk-faint": {"type": "color", "value": "rgb(107,109,113)"},
47
48
  }
48
49
 
49
50
  RULE = "rgba(22,24,28,.16)"
@@ -71,10 +72,22 @@ def T(tag, text, _t=None, _m=None, **st):
71
72
  "style": bp(st, _t, _m) if (st or _t or _m) else None}
72
73
 
73
74
 
74
- def box(attr, style, children, hover=None, _t=None, _m=None):
75
+ # The keyboard is a state too.
76
+ #
77
+ # `focus-visible` was verified by sweep_style_states.py to compile to
78
+ # `.M_EL<n>:FOCUS-VISIBLE` - note the UPPERCASE pseudo-class Mosaic emits, which is
79
+ # why grepping a delivered stylesheet for ":focus-visible" finds nothing at all. Of
80
+ # the seven globally usable states this page used exactly one, `hover`, which means
81
+ # every route through it was invisible to anyone not using a mouse.
82
+ FOCUS_RING = {"customStyles": "outline:2px solid rgb(255,90,54);outline-offset:3px;"}
83
+
84
+
85
+ def box(attr, style, children, hover=None, focus=None, _t=None, _m=None):
75
86
  s = bp(style, _t, _m) or {"&": {"_": {}}}
76
87
  if hover:
77
88
  s["hover"] = {"_": hover}
89
+ if focus:
90
+ s["focus-visible"] = {"_": focus}
78
91
  return {"type": "div", "data": {"attrID": attr}, "style": s, "children": children}
79
92
 
80
93
 
@@ -145,8 +158,13 @@ def figure(attr, num, size="56px", t="44px", m="34px"):
145
158
  "font-variant-numeric:tabular-nums;"}}},
146
159
  "children": [
147
160
  {"type": "div", "data": {"attrID": attr + "-d%d" % j},
161
+ # No `display:inline-block` here: these are flex items, and the
162
+ # spec blockifies a flex item's display value. The declaration was
163
+ # in the stylesheet, correct, and computed to `block` anyway -
164
+ # which is exactly the class of dead code verify_browser.py exists
165
+ # to find, and it found this one.
148
166
  "style": {"&": {"_": {"customStyles":
149
- "overflow:hidden;display:inline-block;"
167
+ "overflow:hidden;"
150
168
  "--d:%dms;" % (j * 70)}}},
151
169
  "children": [T("h3", ch, color={"token": "--mk-ink"}, fontSize=size,
152
170
  fontWeight="600", letterSpacing="-0.03em",
@@ -168,7 +186,8 @@ def clause(num, en, zh, attr):
168
186
  "customStyles": "border-top:1px solid " + RULE + ";"},
169
187
  _m={"gridCols": "48px 1fr"},
170
188
  children=[
171
- mono("§" + num, size="12px", color="--mk-accent", track="0.06em"),
189
+ mono("§" + num, size="12px", color="--mk-accent-ink",
190
+ track="0.06em"),
172
191
  box(attr + "-t", {}, [
173
192
  mono(en, size="11px", color="--mk-muted"),
174
193
  T("h2", zh, color={"token": "--mk-ink"}, fontSize="34px",
@@ -625,7 +644,8 @@ HEADER = box("mk-shell-top", {}, [
625
644
  "transitionAll": "160ms ease",
626
645
  "cursor": "pointer"},
627
646
  "_m": {"fontSize": "12px"}},
628
- "hover": {"_": {"color": {"token": "--mk-accent"}}}},
647
+ "hover": {"_": {"color": {"token": "--mk-accent"}}},
648
+ "focus-visible": {"_": FOCUS_RING}},
629
649
  "text": text}
630
650
  for i, (text, href) in enumerate(NAV)
631
651
  ]},
@@ -634,8 +654,12 @@ HEADER = box("mk-shell-top", {}, [
634
654
  "url": "#contact"},
635
655
  "style": {"&": {"_m": {"display": "none"},
636
656
  "_t": {"fontSize": "12px"},
657
+ # 13px of the bright orange on the glass header is
658
+ # 3.10:1; the darkened cut is 4.9:1. The BORDER
659
+ # stays bright, because a rule is not text and
660
+ # nobody has to read it.
637
661
  "_": {"backgroundColor": "rgba(0,0,0,0)",
638
- "color": {"token": "--mk-accent"},
662
+ "color": {"token": "--mk-accent-ink"},
639
663
  "fontSize": "13px", "fontFamily": MONO,
640
664
  "letterSpacing": "0.06em",
641
665
  "paddingTop": "6px", "paddingBottom": "6px",
@@ -645,7 +669,8 @@ HEADER = box("mk-shell-top", {}, [
645
669
  "color": "rgb(255,90,54)"},
646
670
  "transitionAll": "180ms ease"}},
647
671
  "hover": {"_": {"backgroundColor": {"token": "--mk-accent"},
648
- "color": {"token": "--mk-paper"}}}},
672
+ "color": {"token": "--mk-ink"}}},
673
+ "focus-visible": {"_": FOCUS_RING}},
649
674
  "text": "START A PROJECT"},
650
675
  ]}
651
676
  ])]),
@@ -684,7 +709,7 @@ FOOTER = box("mk-footer",
684
709
  ]),
685
710
  grid("mk-f-links", 2, "28px", tcols=2, mcols=2, children=[
686
711
  box("mk-f-col-0", {}, [
687
- mono("PAGES", size="10px", color="rgb(122,124,132)",
712
+ mono("PAGES", size="10px", color="rgb(128,130,138)",
688
713
  track="0.2em"),
689
714
  box("mk-f-col-0-list",
690
715
  {"marginTop": "16px", "display": "grid", "rowGap": "9px"},
@@ -693,7 +718,7 @@ FOOTER = box("mk-footer",
693
718
  for t in ["作品集", "團隊成員", "關於我們", "服務報價"]]),
694
719
  ]),
695
720
  box("mk-f-col-1", {}, [
696
- mono("LEARN", size="10px", color="rgb(122,124,132)",
721
+ mono("LEARN", size="10px", color="rgb(128,130,138)",
697
722
  track="0.2em"),
698
723
  box("mk-f-col-1-list",
699
724
  {"marginTop": "16px", "display": "grid", "rowGap": "9px"},
@@ -713,9 +738,9 @@ FOOTER = box("mk-footer",
713
738
  "customStyles": "flex-wrap:wrap;"}, None, None),
714
739
  "children": [
715
740
  mono("© 2026 MOKSA WEB — ALL RIGHTS RESERVED", size="10px",
716
- color="rgb(112,114,122)", track="0.1em"),
741
+ color="rgb(128,130,138)", track="0.1em"),
717
742
  mono("BUILT HEADLESS ON MOSAIC", size="10px",
718
- color="rgb(112,114,122)", track="0.1em"),
743
+ color="rgb(128,130,138)", track="0.1em"),
719
744
  ]}]),
720
745
  ])])
721
746
 
@@ -767,7 +792,7 @@ MASTHEAD = section("mk-mast", [wrap("mk-mast-in", [
767
792
  {"type": "button", "data": {"attrID": "mk-cta-%d" % n, "url": href},
768
793
  "style": {"&": {"_": dict(
769
794
  {"backgroundColor": {"token": "--mk-accent"},
770
- "color": {"token": "--mk-paper"},
795
+ "color": {"token": "--mk-ink"},
771
796
  "paddingLeft": "22px", "paddingRight": "22px",
772
797
  "paddingTop": "13px", "paddingBottom": "13px",
773
798
  "customStyles": "border:1px solid rgb(255,90,54);"}
@@ -779,6 +804,7 @@ MASTHEAD = section("mk-mast", [wrap("mk-mast-in", [
779
804
  "border-bottom-color:rgb(22,24,28);"},
780
805
  fontSize="13px", fontFamily=MONO, letterSpacing="0.1em",
781
806
  radius="0px", cursor="pointer", transitionAll="180ms ease")},
807
+ "focus-visible": {"_": FOCUS_RING},
782
808
  "hover": {"_": ({"backgroundColor": {"token": "--mk-ink"},
783
809
  "customStyles": "border:1px solid rgb(22,24,28);"}
784
810
  if n == 1
@@ -831,7 +857,7 @@ MASTHEAD = section("mk-mast", [wrap("mk-mast-in", [
831
857
  {"display": "flex", "alignItems": "baseline",
832
858
  "columnGap": "3px", "marginTop": "12px"},
833
859
  [figure("mk-fig-%d" % i, num),
834
- T("p", "+", color={"token": "--mk-accent"},
860
+ T("p", "+", color={"token": "--mk-accent-ink"},
835
861
  fontSize="22px", fontFamily=DISPLAY, fontWeight="600",
836
862
  _m={"fontSize": "16px"})]),
837
863
  T("p", zh, color={"token": "--mk-muted"}, fontSize="12px",
@@ -868,7 +894,8 @@ SERVICE_SEC = section("services", [wrap("mk-svc-in", [
868
894
  children=[
869
895
  mono(num, size="12px", color="--mk-faint", track="0.06em"),
870
896
  box("mk-svc-t-%d" % i, {}, [
871
- mono(en, size="10px", color="--mk-accent", track="0.16em"),
897
+ mono(en, size="10px", color="--mk-accent-ink",
898
+ track="0.16em"),
872
899
  T("h3", zh, color={"token": "--mk-ink"}, fontSize="19px",
873
900
  fontWeight="500", marginTop="9px", fontFamily=CJK,
874
901
  letterSpacing="0.01em"),
@@ -903,7 +930,8 @@ PROCESS_SEC = section("process", [wrap("mk-proc-in", [
903
930
  T("h3", zh, color={"token": "--mk-ink"}, fontSize="19px",
904
931
  fontWeight="500", fontFamily=CJK, marginTop="10px",
905
932
  letterSpacing="0.01em"),
906
- mono(en, size="10px", color="--mk-accent", track="0.16em",
933
+ mono(en, size="10px", color="--mk-accent-ink",
934
+ track="0.16em",
907
935
  marginTop="7px"),
908
936
  T("p", body, color={"token": "--mk-muted"},
909
937
  fontSize="13px", lineHeight="2", marginTop="12px"),
@@ -924,7 +952,15 @@ STACK_SEC = section("stack", [wrap("mk-stack-in", [
924
952
  {"display": "grid", "gridCols": "160px 1fr", "columnGap": "24px",
925
953
  "alignItems": "baseline",
926
954
  "paddingTop": "18px", "paddingBottom": "18px",
927
- "customStyles": "border-top:1px solid " + RULE + ";"},
955
+ # Each row arrives on its own, staggered by pushing its
956
+ # animation-range further down the scroll rather than by a
957
+ # delay: a scroll-driven animation has no clock to delay
958
+ # against, so the offset has to live in the range.
959
+ "customStyles":
960
+ "border-top:1px solid " + RULE + ";"
961
+ "animation:mk-rise both;animation-timeline:view();"
962
+ "animation-range:entry %d%% cover %d%%;"
963
+ % (2 + i * 4, 18 + i * 4)},
928
964
  _m={"gridCols": "1fr", "rowGap": "10px",
929
965
  "paddingTop": "16px", "paddingBottom": "16px"},
930
966
  children=[
@@ -992,7 +1028,8 @@ WORK_SEC = section("works", [wrap("mk-works-in", [
992
1028
  fontWeight="500", fontFamily=CJK, letterSpacing="0.01em",
993
1029
  _m={"fontSize": "16px"}),
994
1030
  mono(cat, size="11px", color="--mk-muted", track="0.1em"),
995
- mono(domain, size="12px", color="--mk-accent", track="0.02em"),
1031
+ mono(domain, size="12px", color="--mk-accent-ink",
1032
+ track="0.02em"),
996
1033
  ])
997
1034
  for i, (name, cat, domain) in enumerate(WORKS)
998
1035
  ]),
@@ -1034,7 +1071,7 @@ PRODUCT_SEC = section("products", [wrap("mk-prod-in", [
1034
1071
  "paddingTop": "20px", "paddingBottom": "20px"},
1035
1072
  children=[
1036
1073
  mono("%02d" % (i + 1), size="12px",
1037
- color="rgb(122,124,132)", track="0.06em"),
1074
+ color="rgb(128,130,138)", track="0.06em"),
1038
1075
  box("mk-prod-t-%d" % i, {}, [
1039
1076
  mono(en, size="10px", color="--mk-accent",
1040
1077
  track="0.16em"),
@@ -1081,7 +1118,7 @@ VOICE_SEC = section("mk-voices", [wrap("mk-voice-in", [
1081
1118
  "customStyles": "border-top:1px solid " + RULE + ";"
1082
1119
  "border-left:0;"},
1083
1120
  children=[
1084
- mono("“", size="24px", color="--mk-accent", track="0"),
1121
+ mono("“", size="24px", color="--mk-accent-ink", track="0"),
1085
1122
  T("p", quote, color={"token": "--mk-ink"}, fontSize="14px",
1086
1123
  lineHeight="2.05", marginTop="4px"),
1087
1124
  box("mk-voice-a-%d" % i, {"marginTop": "22px"}, [
@@ -1104,7 +1141,8 @@ CONTACT = section("contact", [wrap("mk-contact-in", [
1104
1141
  "customStyles": "border-top:1px solid " + RULE + ";"},
1105
1142
  _m={"gridCols": "48px 1fr"},
1106
1143
  children=[
1107
- mono("§07", size="12px", color="--mk-accent", track="0.06em"),
1144
+ mono("§07", size="12px", color="--mk-accent-ink",
1145
+ track="0.06em"),
1108
1146
  box("mk-contact-head-t", {}, [
1109
1147
  mono("START A PROJECT", size="11px", color="--mk-muted"),
1110
1148
  ml("h2", "準備好升級\n你的數位競爭力了嗎?",
@@ -1135,7 +1173,8 @@ CONTACT = section("contact", [wrap("mk-contact-in", [
1135
1173
  _m={"fontSize": "13px"}),
1136
1174
  ])
1137
1175
  for i, (k, v, vc) in enumerate([
1138
- ("EMAIL", "services@moksaweb.com", "--mk-accent"),
1176
+ ("EMAIL", "services@moksaweb.com",
1177
+ "--mk-accent-ink"),
1139
1178
  ("PHONE", "+886-958-839-939", "--mk-ink"),
1140
1179
  ("LOCATION", "TAICHUNG, TAIWAN", "--mk-ink"),
1141
1180
  ("RESPONSE", "WITHIN 1 BUSINESS DAY", "--mk-ink"),
@@ -1160,7 +1199,8 @@ INDEX = box("mk-index", {}, [
1160
1199
  "fontFamily": MONO, "fontSize": "10px",
1161
1200
  "letterSpacing": "0.14em",
1162
1201
  "transitionAll": "220ms ease"}},
1163
- "hover": {"_": {"color": {"token": "--mk-ink"}}}},
1202
+ "hover": {"_": {"color": {"token": "--mk-ink"}}},
1203
+ "focus-visible": {"_": FOCUS_RING}},
1164
1204
  "text": "§" + num + " " + name}
1165
1205
  for i, (num, name, href, _tl) in enumerate(CLAUSES)
1166
1206
  ])