@homepages/template-kit 0.8.1-dev-20260718224903 → 0.9.0-dev-20260719205406

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +2 -2
  3. package/dist/canvas-core/chrome-css.d.ts +24 -0
  4. package/dist/canvas-core/chrome-css.js +403 -0
  5. package/dist/canvas-core/chrome.d.ts +49 -0
  6. package/dist/canvas-core/chrome.js +147 -0
  7. package/dist/canvas-core/frame.d.ts +20 -0
  8. package/dist/canvas-core/frame.js +23 -0
  9. package/dist/canvas-core/hidden-tabs.d.ts +16 -0
  10. package/dist/canvas-core/hidden-tabs.js +84 -0
  11. package/dist/canvas-core/resolve-target.js +55 -0
  12. package/dist/canvas-core/scroll.d.ts +10 -0
  13. package/dist/canvas-core/scroll.js +15 -0
  14. package/dist/canvas-core/section-dom.d.ts +6 -0
  15. package/dist/canvas-core/section-dom.js +43 -0
  16. package/dist/canvas-core/srcdoc.d.ts +8 -0
  17. package/dist/canvas-core/srcdoc.js +77 -0
  18. package/dist/canvas-core/types.d.ts +76 -0
  19. package/dist/canvas-core.d.ts +9 -0
  20. package/dist/canvas-core.js +9 -0
  21. package/dist/cli/check/css.js +2 -2
  22. package/dist/cli/check/loader.js +1 -1
  23. package/dist/cli/check/stages/deps.js +1 -1
  24. package/dist/cli/check/stages/size.js +2 -2
  25. package/dist/cli/check/stages/tree.js +1 -1
  26. package/dist/cli/check/stages/validate/index.js +1 -1
  27. package/dist/cli/check/stages/validate/nav-contract.js +1 -1
  28. package/dist/cli/check/stages/validate/orchestrator.js +1 -1
  29. package/dist/cli/check/workspace.js +2 -2
  30. package/dist/cli/dev/build-css.js +1 -1
  31. package/dist/cli/dev/discover.js +2 -2
  32. package/dist/cli/dev/inspect.js +1 -1
  33. package/dist/cli/dev/island-bootstrap.js +1 -1
  34. package/dist/cli/dev/island-canvas-bootstrap.js +37 -5
  35. package/dist/cli/dev/section-page.js +1 -1
  36. package/dist/cli/dev/server.js +9 -3
  37. package/dist/cli/dev/tailwind.js +1 -1
  38. package/dist/cli/dev/tsconfig-paths-plugin.js +1 -1
  39. package/dist/cli/dev/workspace.js +2 -2
  40. package/dist/cli/link/index.js +1 -1
  41. package/dist/cli/link/overlay.js +1 -1
  42. package/dist/cli/new/emit.js +1 -1
  43. package/dist/cli/new/index.js +1 -1
  44. package/dist/cli/new/scaffold-assets.js +2 -2
  45. package/dist/cli/pack/collect.js +1 -1
  46. package/dist/cli/pack/guards.js +1 -1
  47. package/dist/cli/pack/index.js +1 -1
  48. package/dist/cli/theme/generate.js +1 -1
  49. package/dist/cli/theme/load-theme.js +1 -1
  50. package/dist/dev-client/assets/index-CJa9G1Na.css +1 -0
  51. package/dist/dev-client/assets/index-C_1atZs4.js +450 -0
  52. package/dist/dev-client/index.html +2 -2
  53. package/dist/islands/client-directive.d.ts +7 -0
  54. package/dist/{eslint/is-client-file.js → islands/client-directive.js} +6 -3
  55. package/dist/islands/detect.d.ts +1 -1
  56. package/dist/islands/detect.js +1 -1
  57. package/dist/islands/discover.js +1 -1
  58. package/dist/islands/esbuild-plugin.js +1 -1
  59. package/dist/islands/wrap.js +1 -1
  60. package/dist/package.js +1 -1
  61. package/dist/rules.d.ts +176 -0
  62. package/dist/rules.js +209 -0
  63. package/dist/ssr.d.ts +2 -2
  64. package/dist/ssr.js +2 -2
  65. package/dist/tokens.css +21 -0
  66. package/guide/INDEX.md +2 -2
  67. package/guide/check.md +5 -5
  68. package/guide/dev.md +39 -30
  69. package/guide/eslint.md +13 -10
  70. package/guide/llms.txt +4 -3
  71. package/guide/quickstart.md +1 -1
  72. package/guide/rules/INDEX.md +3 -2
  73. package/guide/upgrading.md +18 -2
  74. package/package.json +8 -4
  75. package/tsconfig.json +1 -1
  76. package/dist/dev-client/assets/index-3Gn34X_P.js +0 -50
  77. package/dist/dev-client/assets/index-DxQeiHfu.css +0 -1
  78. package/dist/eslint/is-client-file.d.ts +0 -4
  79. package/dist/eslint/rules/image-bare-needs-reason.js +0 -43
  80. package/dist/eslint/rules/no-client-directive-in-contract.js +0 -31
  81. package/dist/eslint/rules/no-client-runtime-in-server.js +0 -103
  82. package/dist/eslint/rules/no-css-import-from-render-path.js +0 -29
  83. package/dist/eslint/rules/no-hex.js +0 -188
  84. package/dist/eslint/rules/no-inline-style.js +0 -27
  85. package/dist/eslint/rules/no-nondeterminism.js +0 -48
  86. package/dist/eslint/rules/no-raw-element.js +0 -38
  87. package/dist/eslint/rules/props-from-schema.js +0 -37
  88. package/dist/eslint/rules/require-island-editor.js +0 -74
  89. package/dist/eslint/rules/serializable-island-props.js +0 -112
  90. package/dist/eslint/rules/slot-marker-literal.js +0 -38
  91. package/dist/eslint.d.ts +0 -6
  92. package/dist/eslint.js +0 -80
  93. package/dist/rules/registry.js +0 -16
package/CHANGELOG.md CHANGED
@@ -1,9 +1,37 @@
1
1
  # @homepages/template-kit
2
2
 
3
- ## 0.8.1-dev-20260718224903
3
+ ## 0.9.0-dev-20260719205406
4
+
5
+ ### Minor Changes
6
+
7
+ - 5030122: The authoring ESLint rules move out of `@homepages/template-kit` into the new
8
+ `@homepages/eslint-plugin-template`, which takes the kit as a peerDependency.
9
+
10
+ The kit's `./eslint` subpath is removed with no shim. Replace
11
+
12
+ import kit from "@homepages/template-kit/eslint";
13
+
14
+ with
15
+
16
+ import kit from "@homepages/eslint-plugin-template";
17
+
18
+ and add `@homepages/eslint-plugin-template` to devDependencies. Rule ids are
19
+ unchanged — the namespace is still `template-kit/`, so any rule-specific
20
+ `eslint-disable` comment and any `rules` override keep working.
21
+
22
+ The kit also gains a `./rules` subpath exposing the rule-id registry, and its
23
+ `./ssr` entry now exports the `"use client"` resolver (`isClientFile`,
24
+ `resolveImportedFile`, `resetClientFileCache`) that the plugin's rules consume
25
+ over the peer edge.
4
26
 
5
27
  ### Patch Changes
6
28
 
29
+ - 10f53a7: `template-kit dev`'s canvas hover/select ring no longer overflows the page's
30
+ x-axis when hovering a full-bleed section (e.g. a hero or header flush with
31
+ both page edges). The ring's geometry inflated the target's bounding rect by
32
+ a fixed offset on every side with no bound, so a rect already touching the
33
+ document edge pushed the ring 2px past it, growing the iframe's horizontal
34
+ scroll extent. The ring box is now clamped to `[0, containerWidth]`.
7
35
  - f3f6d70: `template-kit check` no longer fails on a tsconfig `paths` alias (e.g.
8
36
  `"@/platform/*": ["../platform/*"]`) or on a section whose contract files
9
37
  share a module large enough for esbuild to code-split. Previously the
@@ -43,6 +71,7 @@
43
71
  (`ci/build-css.ts`) has always assembled it. `fontFaces` is still prepended
44
72
  to the final bundle separately, since a nested `@import url(...)` doesn't
45
73
  hoist out of an imported partial.
74
+ - 2e36277: Escape `>` and `&` as well as `<` in the dev server's island map, matching the escaping used by published pages.
46
75
  - 1c4c7dd: Island hydration is now nesting-safe: `hydrateIslands` skips any island marker
47
76
  nested inside another marker, so an island can render another island without the
48
77
  child being hydrated twice (once by its parent root, once independently). Enables
package/README.md CHANGED
@@ -17,7 +17,7 @@ The fastest way to start is the workspace scaffolder — it creates a workspace
17
17
  working starter template and the full toolchain already wired:
18
18
 
19
19
  ```bash
20
- npm create homepages-workspace <name>
20
+ npm create @homepages/workspace <name>
21
21
  cd <name>
22
22
  npm install
23
23
  npm run dev # preview the starter; the island hydrates
@@ -97,7 +97,7 @@ Full contract: **[guide/primitives.md](guide/primitives.md)**.
97
97
  | `@homepages/template-kit/browser` | Browser helpers for published pages (site config, signed ingest) |
98
98
  | `@homepages/template-kit/styles.css` | The global Tailwind preamble: the breakpoint ladder, the spacing base unit, the motion defaults, kit source registration |
99
99
  | `@homepages/template-kit/base.css` | Cascade-layer order, the three reset gaps Tailwind's preflight leaves, `.tr-section`, and the motion tokens |
100
- | `@homepages/template-kit/eslint` | ESLint flat-config preset |
100
+ | `@homepages/eslint-plugin-template` | ESLint flat-config preset (its own package) |
101
101
  | `@homepages/template-kit/tsconfig` | tsconfig preset (`"extends"`) |
102
102
 
103
103
  One specifier for everything you write a section against. The exceptions are
@@ -0,0 +1,24 @@
1
+ import { MarkerAttrs } from "./types.js";
2
+ //#region src/canvas-core/chrome-css.d.ts
3
+ /**
4
+ * Custom properties the chrome CSS reads but deliberately does not declare —
5
+ * the host must set them on the iframe document's root element. This is the
6
+ * host's obligation, not a suggestion: an adapter that skips one leaves the
7
+ * declared inline fallback in force, which renders plausibly and silently
8
+ * wrong rather than failing.
9
+ *
10
+ * - `--we-panel-w`: the floating panel's measured width, mirrored onto the
11
+ * iframe root so the page card's right padding clears it. Unset, the card
12
+ * lays out against the 436px base-width fallback and drifts out of alignment
13
+ * whenever the real panel is any other width.
14
+ * - `--we-content`: the host's interior scaling unit, mirrored in so hidden-tab
15
+ * chrome scales with the rest of the UI. Unset, it pins to the 1px fallback
16
+ * and the tabs stay laptop-sized while every surrounding surface scales.
17
+ *
18
+ * Anything added here must actually be read by the kernel; the token test
19
+ * fails on a stale entry.
20
+ */
21
+ declare const HOST_INJECTED_PROPERTIES: readonly ["--we-panel-w", "--we-content"];
22
+ declare function buildChromeCss(markers: MarkerAttrs): string;
23
+ //#endregion
24
+ export { HOST_INJECTED_PROPERTIES, buildChromeCss };
@@ -0,0 +1,403 @@
1
+ import { TAB_TUCK } from "./hidden-tabs.js";
2
+
3
+ //#region src/canvas-core/chrome-css.ts
4
+ /**
5
+ * Custom properties the chrome CSS reads but deliberately does not declare —
6
+ * the host must set them on the iframe document's root element. This is the
7
+ * host's obligation, not a suggestion: an adapter that skips one leaves the
8
+ * declared inline fallback in force, which renders plausibly and silently
9
+ * wrong rather than failing.
10
+ *
11
+ * - `--we-panel-w`: the floating panel's measured width, mirrored onto the
12
+ * iframe root so the page card's right padding clears it. Unset, the card
13
+ * lays out against the 436px base-width fallback and drifts out of alignment
14
+ * whenever the real panel is any other width.
15
+ * - `--we-content`: the host's interior scaling unit, mirrored in so hidden-tab
16
+ * chrome scales with the rest of the UI. Unset, it pins to the 1px fallback
17
+ * and the tabs stay laptop-sized while every surrounding surface scales.
18
+ *
19
+ * Anything added here must actually be read by the kernel; the token test
20
+ * fails on a stale entry.
21
+ */
22
+ const HOST_INJECTED_PROPERTIES = ["--we-panel-w", "--we-content"];
23
+ function buildChromeCss(markers) {
24
+ return `
25
+ html, body { background: transparent; }
26
+ body {
27
+ --we-page-radius: 14px;
28
+ margin: 0;
29
+ /* Padding aligns the page card with the floating panel and centers
30
+ it in the visible canvas area. Top/bottom match the panel's
31
+ top-5/bottom-5 inset (20px). Left is 20px. The right constant
32
+ (40px) covers the panel's right-5 inset (20px) plus an equal
33
+ 20px gap, giving symmetric breathing room on both sides of the
34
+ card between the canvas's left edge and the panel's left edge.
35
+ Fallback 436px matches the panel's base CSS width. */
36
+ padding: 20px calc(var(--we-panel-w, 436px) + 40px) 20px 20px;
37
+ /* Body is the container for the auto-fit calc on #we-zoom. With
38
+ container-type: inline-size, descendants resolve 100cqw to the
39
+ body's content-box width — exactly the width we want the card
40
+ to scale into. */
41
+ container-type: inline-size;
42
+ position: relative;
43
+ display: flex;
44
+ justify-content: center;
45
+ align-items: flex-start;
46
+ min-height: 100%;
47
+ overflow: auto;
48
+ color: #14180F;
49
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
50
+ /* Dotted canvas surface. background-attachment: local makes the grid
51
+ scroll together with the page card so the canvas + website read as
52
+ one scene being panned, not a card sliding over a fixed grid. */
53
+ background-image: radial-gradient(rgba(20, 18, 13, 0.13) 1px, transparent 1.2px);
54
+ background-size: 20px 20px;
55
+ background-position: 10px 10px;
56
+ background-attachment: local;
57
+ }
58
+ /* Hide the native scrollbar on whichever element ends up being the
59
+ scroll container. Some browsers route the scroll to the root
60
+ element (html) rather than body, so cover both. Wheel / trackpad /
61
+ keyboard and programmatic scrollTo still work. */
62
+ html, body { scrollbar-width: none; -ms-overflow-style: none; }
63
+ html::-webkit-scrollbar,
64
+ body::-webkit-scrollbar { width: 0; height: 0; display: none; }
65
+ #we-zoom {
66
+ /* Auto-fit + manual zoom, declarative.
67
+ Two-slope formula: below ~1472px canvas (≤1536px viewport) slope 1
68
+ (0.8×) keeps the card at 80% of canvas width — the original
69
+ breathing-room factor. Above that breakpoint slope 2 (0.214×+0.600)
70
+ takes over, growing sub-linearly so the percentage of canvas the
71
+ card occupies shrinks on wider monitors while absolute pixel width
72
+ keeps growing. At a 1920px monitor this yields ~12.5% smaller than
73
+ the old native-1440px cap, restoring the birds-eye view. The outer
74
+ min(1,...) still prevents upscaling beyond native width.
75
+
76
+ --user-zoom is the manual multiplier the toolbar writes (1.0 = at
77
+ fit / "100%"). The browser recomputes on every resize — no host JS
78
+ needed.
79
+
80
+ Scale via the zoom property, NOT transform: scale(). A transform
81
+ only shrinks the PAINT — the layout box keeps the full unscaled
82
+ 1440px footprint, so the body (scroll container) reserves space the
83
+ shrunken page no longer fills and the canvas scrolls far past the
84
+ bottom. zoom scales the layout box too, so scrollHeight tracks the
85
+ visible page exactly. getBoundingClientRect() reports visual coords
86
+ under either, so overlay-ring + scroll-into-view math in
87
+ iframe-canvas.tsx is unaffected; flex justify-content:center
88
+ re-centers the smaller box, so no transform-origin is needed. */
89
+ --user-zoom: 1;
90
+ --fit-zoom: min(0.9, min(
91
+ calc(0.72 * 100cqw / 1440px),
92
+ calc(0.1926 * 100cqw / 1440px + 0.540)
93
+ ));
94
+ zoom: calc(var(--fit-zoom) * var(--user-zoom));
95
+ transition: zoom 150ms cubic-bezier(0.4, 0, 0.2, 1);
96
+ flex-shrink: 0;
97
+ /* Positioned + stacked above #we-htabs (z-index:1) so the page's
98
+ drop-shadow falls over the hidden-tab's inner edge, nestling it against
99
+ the frame. #we-chrome-overlay (z-index:100) stays above. */
100
+ position: relative;
101
+ z-index: 2;
102
+ }
103
+ #we-page {
104
+ width: 1440px;
105
+ overflow: hidden;
106
+ border-radius: var(--we-page-radius);
107
+ background: #f5f3ee;
108
+ padding: 0;
109
+ box-shadow:
110
+ 0 1px 0 rgba(20,18,13,0.06),
111
+ 0 2px 6px -2px rgba(20,18,13,0.10),
112
+ 0 12px 28px -8px rgba(20,18,13,0.18),
113
+ 0 32px 64px -20px rgba(20,18,13,0.28);
114
+ }
115
+
116
+ /* Section wrapper -- structure only. No selection chrome here;
117
+ rings are painted by #we-chrome-overlay (see below). Zero margin
118
+ + padding means sections butt edge-to-edge, matching publisher. */
119
+ [${markers.sectionInstanceId}] {
120
+ position: relative;
121
+ margin: 0;
122
+ padding: 0;
123
+ cursor: pointer;
124
+ display: grid;
125
+ grid-template-rows: 1fr;
126
+ /* Reveal transition — governs the hidden→visible direction because
127
+ CSS uses the destination state's transition property. Spring easing
128
+ gives a snappy entrance. */
129
+ transition:
130
+ grid-template-rows 200ms cubic-bezier(0.16, 1, 0.3, 1),
131
+ opacity 180ms cubic-bezier(0.16, 1, 0.3, 1);
132
+ }
133
+
134
+ /* Section title pill -- lives in #we-chrome-overlay above the rings
135
+ so it straddles the top border without being covered. Sections
136
+ only; slots get the outline ring but no on-canvas label. */
137
+ .we-chrome-label {
138
+ position: absolute;
139
+ pointer-events: none;
140
+ z-index: 1;
141
+ background: rgba(229,184,50,0.95);
142
+ color: #282c32;
143
+ padding: 3px 8px;
144
+ border-radius: 4px;
145
+ font-size: 10.5px;
146
+ font-weight: 600;
147
+ line-height: 1;
148
+ letter-spacing: 0.02em;
149
+ text-transform: uppercase;
150
+ white-space: nowrap;
151
+ opacity: 0;
152
+ transform: translateY(calc(-50% - 4px)) scale(0.96);
153
+ transition:
154
+ opacity 60ms ease-in,
155
+ transform 60ms ease-in;
156
+ }
157
+ .we-chrome-label[data-active="true"] {
158
+ opacity: 1;
159
+ transform: translateY(-50%) scale(1);
160
+ transition:
161
+ opacity 130ms cubic-bezier(0.16, 1, 0.3, 1) 50ms,
162
+ transform 130ms cubic-bezier(0.16, 1, 0.3, 1) 50ms;
163
+ }
164
+
165
+ /* Hidden state -- collapse the section out of the page so it reflows shut.
166
+ The 0-height wrapper stays in flow as the anchor for the section's
167
+ floating edge tab (built in #we-htabs by iframe-canvas.tsx). See
168
+ docs/superpowers/specs/2026-06-02-hidden-section-edge-tabs-design.md. */
169
+ [${markers.sectionInstanceId}][data-hidden="true"] {
170
+ grid-template-rows: 0fr;
171
+ opacity: 0;
172
+ pointer-events: none;
173
+ /* Collapse transition — governs the visible→hidden direction.
174
+ Faster + accelerating so the hide feels decisive. */
175
+ transition:
176
+ grid-template-rows 150ms cubic-bezier(0.4, 0, 1, 1),
177
+ opacity 120ms cubic-bezier(0.4, 0, 1, 1);
178
+ }
179
+
180
+ /* Slot wrapper -- structure only. No outline here; the overlay
181
+ ring handles slot hover + selection too. */
182
+ [${markers.slotId}] {
183
+ position: relative;
184
+ cursor: pointer;
185
+ }
186
+
187
+ /* Slot-group container -- the gaps between grouped members (dividers,
188
+ padding) carry no slot id, so give the group container itself
189
+ the pointer cursor: the whole group reads as one clickable unit, and
190
+ the overlay ring outlines this container (see iframe-canvas.tsx). */
191
+ [${markers.slotGroup}],
192
+ [${markers.slotItem}] {
193
+ position: relative;
194
+ cursor: pointer;
195
+ }
196
+
197
+ /* Per-section body holds the API HTML.
198
+ 1. position:relative gives the body a positioning context for any
199
+ body-scoped chrome or future body-only effects.
200
+ 2. isolation:isolate SEALS the section's z-index hierarchy
201
+ inside this stacking context. Without it, any positioned
202
+ descendant with z-index > 0 (the header's sticky+z-50, a
203
+ dropdown's z-40, a sticky CTA) creates a stacking context
204
+ that escapes upward. Sections never need to know the
205
+ editor chrome exists -- internal z-index is unconstrained. */
206
+ /* overflow:hidden clips section content as the grid row collapses;
207
+ min-height:0 lets the grid child shrink below its content size.
208
+ Constraint: overflow:hidden is permanent (not just during animation), so
209
+ section content must not overflow its section-body boundary — sticky
210
+ elements, absolutely-positioned dropdowns, or shadows that bleed outside
211
+ the section edge will be silently clipped. Template sections are
212
+ self-contained blocks; this is safe in practice. */
213
+ .we-section-body { position: relative; isolation: isolate; overflow: hidden; min-height: 0; }
214
+
215
+ /* Chrome overlay -- sibling of #we-zoom inside the iframe body.
216
+ Sized to body's padding box; rings inside are absolutely
217
+ positioned by JS using getBoundingClientRect + window.scrollY/X.
218
+ Lives OUTSIDE #we-page so rings ride above the page's rounded
219
+ clip and any section's overflow:hidden. pointer-events:none so
220
+ clicks pass through to the section/slot underneath -- the
221
+ click resolver remains on doc.body. */
222
+ #we-chrome-overlay {
223
+ position: absolute;
224
+ inset: 0;
225
+ pointer-events: none;
226
+ z-index: 100;
227
+ }
228
+ .we-chrome-ring {
229
+ position: absolute;
230
+ pointer-events: none;
231
+ border: 2px solid transparent;
232
+ border-radius: 6px;
233
+ opacity: 0;
234
+ box-sizing: border-box;
235
+ }
236
+ /* Hover ring -- instant on cursor arrival, fades out on leave.
237
+ Position updates instantly (no top/left transition) so dragging
238
+ across elements never flickers. Ink stroke + white drop-shadow
239
+ halo (same trick as crop handles in globals.css) keeps the ring
240
+ readable on both light sections and dark ones like footer. */
241
+ .we-chrome-ring--hover {
242
+ border-color: rgba(15,42,34,0.85);
243
+ filter: drop-shadow(0 0 1px rgba(255,255,255,0.95));
244
+ transition: opacity 70ms ease-in;
245
+ }
246
+ .we-chrome-ring--hover[data-active="true"] {
247
+ opacity: 1;
248
+ transition: opacity 100ms cubic-bezier(0.16, 1, 0.3, 1);
249
+ }
250
+ /* Selection ring -- appears instantly (continuous with hover),
251
+ then morphs ink → gold. No position transition so resize
252
+ tracking stays tight. Same ink + white halo as hover. */
253
+ .we-chrome-ring--selection {
254
+ border-color: rgba(15,42,34,0.85);
255
+ filter: drop-shadow(0 0 1px rgba(255,255,255,0.95));
256
+ transform: scale(1);
257
+ box-shadow: none;
258
+ transition:
259
+ opacity 50ms ease-in,
260
+ border-color 160ms cubic-bezier(0.2, 0, 0, 1),
261
+ box-shadow 160ms cubic-bezier(0.2, 0, 0, 1);
262
+ }
263
+ .we-chrome-ring--selection[data-active="true"] {
264
+ opacity: 1;
265
+ /* Instant show on select; only border + glow animate */
266
+ transition:
267
+ opacity 0ms,
268
+ border-color 180ms cubic-bezier(0.2, 0, 0, 1),
269
+ box-shadow 180ms cubic-bezier(0.2, 0, 0, 1);
270
+ }
271
+ .we-chrome-ring--selection[data-kind="section"],
272
+ .we-chrome-ring--selection[data-kind="slot"] {
273
+ border-color: #E5B832;
274
+ filter: drop-shadow(0 0 1px rgba(255,255,255,0.95));
275
+ box-shadow: 0 0 0 1px rgba(229,184,50,0.25), 0 8px 24px -10px rgba(229,184,50,0.35);
276
+ }
277
+
278
+ @media (prefers-reduced-motion: reduce) {
279
+ .we-chrome-ring,
280
+ .we-chrome-label,
281
+ [${markers.sectionInstanceId}] {
282
+ transition: none !important;
283
+ }
284
+ }
285
+
286
+ /* Hidden-section edge tabs -------------------------------------
287
+ One dim chip-tab per hidden section, pinned to the page's left edge at
288
+ the collapsed wrapper's reflow seam. Built + positioned by
289
+ iframe-canvas.tsx (positionHiddenTabs); de-collided via placeTabs. Lives
290
+ in its own layer (sibling of #we-zoom) so it rides with page scroll and
291
+ stays a constant size at any zoom — like the rings. Colors are literals
292
+ (the iframe doesn't inherit dashboard Tailwind tokens). */
293
+ #we-htabs {
294
+ position: absolute;
295
+ inset: 0;
296
+ pointer-events: none;
297
+ z-index: 1;
298
+ }
299
+ .we-htab {
300
+ position: absolute;
301
+ transform: translateY(-50%);
302
+ display: flex;
303
+ align-items: center;
304
+ gap: calc(9 * var(--we-content, 1px));
305
+ height: calc(34 * var(--we-content, 1px));
306
+ padding: 0 calc(${12 + 16} * var(--we-content, 1px)) 0 calc(13 * var(--we-content, 1px));
307
+ background: #ffffff;
308
+ border: 1px solid #ebebe6;
309
+ border-radius: calc(9 * var(--we-content, 1px)) 0 0 calc(9 * var(--we-content, 1px));
310
+ box-shadow: 0 10px 24px -12px rgba(20,18,13,0.34);
311
+ color: #4a4842;
312
+ font: inherit;
313
+ cursor: pointer;
314
+ opacity: 1;
315
+ pointer-events: auto;
316
+ transition: transform 0.2s cubic-bezier(0.2,0.7,0.3,1),
317
+ border-color 0.18s, box-shadow 0.18s;
318
+ }
319
+ .we-htab-eye {
320
+ display: grid;
321
+ place-items: center;
322
+ flex: 0 0 auto;
323
+ color: #8a877e;
324
+ transition: color 0.18s;
325
+ }
326
+ .we-htab-eye svg { width: calc(14 * var(--we-content, 1px)); height: calc(14 * var(--we-content, 1px)); }
327
+ .we-htab-name {
328
+ font-size: calc(13 * var(--we-content, 1px));
329
+ font-weight: 600;
330
+ letter-spacing: -0.01em;
331
+ color: #14180F;
332
+ white-space: nowrap;
333
+ }
334
+ .we-htab:hover {
335
+ transform: translateY(-50%) scale(1.07);
336
+ border-color: rgba(15,42,34,0.5);
337
+ box-shadow: 0 13px 30px -12px rgba(15,42,34,0.45);
338
+ }
339
+ .we-htab:hover .we-htab-eye { color: #0F2A22; }
340
+ .we-htab:focus-visible {
341
+ outline: none;
342
+ transform: translateY(-50%) scale(1.07);
343
+ border-color: #0F2A22;
344
+ box-shadow: 0 0 0 3px #F3F6EF, 0 13px 30px -12px rgba(15,42,34,0.45);
345
+ }
346
+ /* Hover/focus swaps the eye-off glyph to the eye glyph in place — no width
347
+ change, no sliding label. Only one glyph is in flow at a time, so the eye
348
+ slot's grid keeps the shown glyph centered. */
349
+ .we-htab-eye-on { display: none; }
350
+ .we-htab:hover .we-htab-eye-off,
351
+ .we-htab:focus-visible .we-htab-eye-off { display: none; }
352
+ .we-htab:hover .we-htab-eye-on,
353
+ .we-htab:focus-visible .we-htab-eye-on { display: block; }
354
+
355
+ /* Render-error placeholder ------------------------------------- */
356
+ .we-render-error {
357
+ margin: 8px 0;
358
+ padding: 24px 16px;
359
+ border-radius: 6px;
360
+ border: 1px dashed #ebebe6;
361
+ background: #ffffff;
362
+ color: #8a877e;
363
+ font-size: 12.5px;
364
+ }
365
+ .we-render-error__title {
366
+ margin: 0;
367
+ color: #4a4842;
368
+ font-weight: 500;
369
+ }
370
+ .we-render-error__detail {
371
+ margin: 4px 0 0 0;
372
+ color: #b9b6ab;
373
+ font-size: 11.5px;
374
+ }
375
+
376
+ /* Island interaction shield: a transparent click layer over an
377
+ island's live surface (editor.liveSurface) (e.g. the neighborhood map canvas). Present until
378
+ the surface's owner (slot/section) is the live selection; a click on it
379
+ falls through to the editor's resolver, which selects the owner. Once the
380
+ owner carries data-we-live the shield is removed, so the live surface (map
381
+ pan/zoom/markers) is interactive. z-index 1 sits on the map canvas's plane
382
+ (later in DOM than the canvas -> above it) but below the floating
383
+ .tr-nbhd__card (z-index 2), so the card's slots stay clickable. */
384
+ .we-enh-shield {
385
+ position: absolute;
386
+ inset: 0;
387
+ z-index: 1;
388
+ cursor: pointer;
389
+ background: transparent;
390
+ }
391
+ [data-we-live] .we-enh-shield { display: none; }
392
+
393
+ /* Image slider: until the slider's \`images\` slot is the live selection, its
394
+ z-index:2 nav arrows would paint above the z-index:1 shield and stay
395
+ clickable, escaping the select-first gate. Disable them so the shield
396
+ catches the first click; restored once .tr-slider carries data-we-live.
397
+ Editor-only — deliberately not in published tokens.css. */
398
+ .tr-slider:not([data-we-live]) .tr-slider__nav { pointer-events: none; }
399
+ `;
400
+ }
401
+
402
+ //#endregion
403
+ export { HOST_INJECTED_PROPERTIES, buildChromeCss };
@@ -0,0 +1,49 @@
1
+ //#region src/canvas-core/chrome.d.ts
2
+ /** How far outside the target the ring sits, in CSS px. With a 2px border-box
3
+ * border, offset = 1 centers the stroke on the element's edge (1px outside,
4
+ * 1px overlapping inside) so the ring reads as flush. */
5
+ declare const RING_OFFSET = 1;
6
+ /** Inset of the section title pill from the target's left edge, in CSS px. */
7
+ declare const LABEL_LEFT_INDENT = 12;
8
+ interface RingRect {
9
+ top: number;
10
+ left: number;
11
+ width: number;
12
+ height: number;
13
+ }
14
+ /**
15
+ * Ring geometry in document space: shift by scroll, inflate by `offset`.
16
+ *
17
+ * `containerWidth` clamps the horizontal edges. A section flush with both page
18
+ * edges would otherwise inflate `offset` px past each one and grow the
19
+ * document's horizontal scroll extent. Omitting it means unclamped: the app's
20
+ * overlay is wider than its page card and never overflows, so clamping it would
21
+ * silently shift every full-bleed ring. A consumer whose overlay is page-width
22
+ * opts in by passing one.
23
+ *
24
+ * Absence — not a sentinel value — is what selects the unclamped branch. A
25
+ * numeric stand-in for "unbounded" would make NaN (plausible out of a detached
26
+ * document's clientWidth arithmetic) take the clamped branch and yield NaN
27
+ * geometry, and would let Number.MAX_SAFE_INTEGER quietly enable the left floor.
28
+ */
29
+ declare function ringBox(rect: RingRect, scroll: {
30
+ x: number;
31
+ y: number;
32
+ }, offset: number, containerWidth?: number): RingRect;
33
+ /** Whether an element has a box a ring can actually wrap. */
34
+ declare function hasMeasurableRingBox(el: HTMLElement): boolean;
35
+ declare function measureRingRect(el: HTMLElement): DOMRect;
36
+ /**
37
+ * Move a ring in #we-chrome-overlay to sit `offset` px outside `target`.
38
+ *
39
+ * `kind` flips a data attribute so CSS can pick a color (gold for sections and
40
+ * slots). The hover ring leaves it unset and uses its own selector.
41
+ */
42
+ declare function positionRing(ring: HTMLElement | null, target: HTMLElement | null, offset: number, kind?: "section" | "slot", containerWidth?: number): void;
43
+ declare function applySelectionKind(ring: HTMLElement, kind: "section" | "slot"): void;
44
+ declare function clearRing(ring: HTMLElement | null): void;
45
+ declare function positionLabel(label: HTMLElement | null, target: HTMLElement | null, text: string): void;
46
+ declare function clearLabel(label: HTMLElement | null): void;
47
+ declare function cssEscape(value: string): string;
48
+ //#endregion
49
+ export { LABEL_LEFT_INDENT, RING_OFFSET, RingRect, applySelectionKind, clearLabel, clearRing, cssEscape, hasMeasurableRingBox, measureRingRect, positionLabel, positionRing, ringBox };
@@ -0,0 +1,147 @@
1
+ //#region src/canvas-core/chrome.ts
2
+ /** How far outside the target the ring sits, in CSS px. With a 2px border-box
3
+ * border, offset = 1 centers the stroke on the element's edge (1px outside,
4
+ * 1px overlapping inside) so the ring reads as flush. */
5
+ const RING_OFFSET = 1;
6
+ /** Inset of the section title pill from the target's left edge, in CSS px. */
7
+ const LABEL_LEFT_INDENT = 12;
8
+ /**
9
+ * Ring geometry in document space: shift by scroll, inflate by `offset`.
10
+ *
11
+ * `containerWidth` clamps the horizontal edges. A section flush with both page
12
+ * edges would otherwise inflate `offset` px past each one and grow the
13
+ * document's horizontal scroll extent. Omitting it means unclamped: the app's
14
+ * overlay is wider than its page card and never overflows, so clamping it would
15
+ * silently shift every full-bleed ring. A consumer whose overlay is page-width
16
+ * opts in by passing one.
17
+ *
18
+ * Absence — not a sentinel value — is what selects the unclamped branch. A
19
+ * numeric stand-in for "unbounded" would make NaN (plausible out of a detached
20
+ * document's clientWidth arithmetic) take the clamped branch and yield NaN
21
+ * geometry, and would let Number.MAX_SAFE_INTEGER quietly enable the left floor.
22
+ */
23
+ function ringBox(rect, scroll, offset, containerWidth) {
24
+ const rawLeft = rect.left + scroll.x - offset;
25
+ const rawRight = rawLeft + rect.width + 2 * offset;
26
+ const left = containerWidth === void 0 ? rawLeft : Math.max(0, rawLeft);
27
+ const right = containerWidth === void 0 ? rawRight : Math.min(containerWidth, rawRight);
28
+ return {
29
+ top: rect.top + scroll.y - offset,
30
+ left,
31
+ width: Math.max(0, right - left),
32
+ height: rect.height + 2 * offset
33
+ };
34
+ }
35
+ /** Whether an element has a box a ring can actually wrap. */
36
+ function hasMeasurableRingBox(el) {
37
+ const rect = measureRingRect(el);
38
+ return rect.width > 0 && rect.height > 0;
39
+ }
40
+ function measureRingRect(el) {
41
+ const rect = el.getBoundingClientRect();
42
+ if (rect.width > 0 && rect.height > 0) return rect;
43
+ let top = Infinity;
44
+ let left = Infinity;
45
+ let right = -Infinity;
46
+ let bottom = -Infinity;
47
+ let found = false;
48
+ for (const child of Array.from(el.children)) {
49
+ const childRect = measureRingRect(child);
50
+ if (childRect.width <= 0 || childRect.height <= 0) continue;
51
+ found = true;
52
+ top = Math.min(top, childRect.top);
53
+ left = Math.min(left, childRect.left);
54
+ right = Math.max(right, childRect.right);
55
+ bottom = Math.max(bottom, childRect.bottom);
56
+ }
57
+ if (!found) return rect;
58
+ return new DOMRect(left, top, right - left, bottom - top);
59
+ }
60
+ /**
61
+ * Move a ring in #we-chrome-overlay to sit `offset` px outside `target`.
62
+ *
63
+ * `kind` flips a data attribute so CSS can pick a color (gold for sections and
64
+ * slots). The hover ring leaves it unset and uses its own selector.
65
+ */
66
+ function positionRing(ring, target, offset, kind, containerWidth) {
67
+ if (!ring) return;
68
+ if (!target) {
69
+ ring.removeAttribute("data-active");
70
+ return;
71
+ }
72
+ const win = target.ownerDocument.defaultView;
73
+ if (!win) return;
74
+ const box = ringBox(measureRingRect(target), {
75
+ x: win.scrollX,
76
+ y: win.scrollY
77
+ }, offset, containerWidth);
78
+ ring.style.top = `${box.top}px`;
79
+ ring.style.left = `${box.left}px`;
80
+ ring.style.width = `${box.width}px`;
81
+ ring.style.height = `${box.height}px`;
82
+ if (kind) ring.setAttribute("data-kind", kind);
83
+ ring.setAttribute("data-active", "true");
84
+ }
85
+ function applySelectionKind(ring, kind) {
86
+ ring.removeAttribute("data-kind");
87
+ requestAnimationFrame(() => {
88
+ requestAnimationFrame(() => {
89
+ ring.setAttribute("data-kind", kind);
90
+ });
91
+ });
92
+ }
93
+ function clearRing(ring) {
94
+ if (!ring) return;
95
+ ring.removeAttribute("data-active");
96
+ ring.removeAttribute("data-kind");
97
+ }
98
+ function positionLabel(label, target, text) {
99
+ if (!label) return;
100
+ if (!target || !text) {
101
+ clearLabel(label);
102
+ return;
103
+ }
104
+ const win = target.ownerDocument.defaultView;
105
+ if (!win) return;
106
+ const rect = measureRingRect(target);
107
+ const wasActive = label.getAttribute("data-active") === "true";
108
+ const textChanged = label.textContent !== text;
109
+ label.textContent = text;
110
+ label.style.top = `${rect.top + win.scrollY}px`;
111
+ label.style.left = `${rect.left + win.scrollX + 12}px`;
112
+ if (wasActive && textChanged) {
113
+ label.removeAttribute("data-active");
114
+ label.offsetWidth;
115
+ }
116
+ label.setAttribute("data-active", "true");
117
+ }
118
+ function clearLabel(label) {
119
+ if (!label) return;
120
+ label.removeAttribute("data-active");
121
+ label.textContent = "";
122
+ }
123
+ function cssEscape(value) {
124
+ if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(value);
125
+ return value.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
126
+ }
127
+ function resolveVisibleSlotEl(scope, slotId, markers) {
128
+ const nodes = scope.querySelectorAll(`[${markers.slotId}="${cssEscape(slotId)}"]`);
129
+ for (const el of Array.from(nodes)) if (hasMeasurableRingBox(el)) return el;
130
+ return nodes[0] ?? null;
131
+ }
132
+ function resolveItemEl(slotEl, selection, markers) {
133
+ if (!selection || selection.kind !== "slot" || selection.item_index == null) return null;
134
+ return slotEl?.querySelector(`[${markers.slotItem}="${cssEscape(String(selection.item_index))}"]`) ?? null;
135
+ }
136
+ function resolveSelectionEl(doc, selection, markers) {
137
+ if (!selection) return null;
138
+ const sec = doc.querySelector(`[${markers.sectionInstanceId}="${cssEscape(selection.instance_id)}"]`);
139
+ if (!sec) return null;
140
+ if (selection.kind === "section") return sec;
141
+ const node = resolveVisibleSlotEl(sec, selection.slot_id, markers);
142
+ if (!node) return null;
143
+ return resolveItemEl(node, selection, markers) ?? node.closest(`[${markers.slotGroup}]`) ?? node;
144
+ }
145
+
146
+ //#endregion
147
+ export { LABEL_LEFT_INDENT, RING_OFFSET, applySelectionKind, clearLabel, clearRing, cssEscape, hasMeasurableRingBox, measureRingRect, positionLabel, positionRing, resolveItemEl, resolveSelectionEl, resolveVisibleSlotEl, ringBox };