@crossworks/share-ui 0.232.66 → 0.232.68

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/share-ui",
3
- "version": "0.232.66",
3
+ "version": "0.232.68",
4
4
  "description": "The server-rendered share surface — the /s/<token> presenters, view-payload contract, mini-app sandbox, and the few primitives they need. Lives in MANTLE (the server renders these), published for jackdaw to consume; may depend only on @mantle/{client-types,content-core} (the jackdaw-repo-split boundary).",
5
5
  "exports": {
6
6
  "./app-presenter": "./src/app-presenter.tsx",
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@firecms/neat": "1.0.2",
39
- "@mantle/client-types": "npm:@crossworks/client-types@0.232.66",
40
- "@mantle/content-core": "npm:@crossworks/content-core@0.232.66",
39
+ "@mantle/client-types": "npm:@crossworks/client-types@0.232.68",
40
+ "@mantle/content-core": "npm:@crossworks/content-core@0.232.68",
41
41
  "@radix-ui/react-label": "^2.1.12",
42
42
  "@radix-ui/react-slot": "^1.3.0",
43
43
  "class-variance-authority": "^0.7.1",
@@ -49,3 +49,49 @@ describe('buildAppFrameHtml — error reporting', () => {
49
49
  expect(html).toContain('<script type="importmap">{"imports":{}}</script>');
50
50
  });
51
51
  });
52
+
53
+ describe('buildAppFrameHtml — the Neat backdrop', () => {
54
+ const SPEC = '{"v":1,"seed":42,"tone":"auto","speed":2}';
55
+ const withNeat = (neatLicense?: string | null) =>
56
+ buildAppFrameHtml({
57
+ bundleCode: 'console.log(1)',
58
+ appCss: '',
59
+ importMapJson: '{"imports":{}}',
60
+ cls: 'dark',
61
+ colorTheme: 'darkmatter',
62
+ viewport: true,
63
+ neatSpec: SPEC,
64
+ neatLicense,
65
+ });
66
+
67
+ it('renders no host div and no runtime script without a spec', () => {
68
+ const html = frame('console.log(1)');
69
+ expect(html).not.toContain('data-neat-spec');
70
+ expect(html).not.toContain('share-page.js');
71
+ });
72
+
73
+ it('renders the host div at z-index:-1 plus the runtime script with a spec', () => {
74
+ const html = withNeat();
75
+ expect(html).toContain('data-neat-spec="{&quot;v&quot;:1');
76
+ expect(html).toContain('z-index:-1');
77
+ expect(html).toContain('src="/share-runtime/share-page.js"');
78
+ // No reader attribute — the runtime must never mount its toggle in a frame.
79
+ expect(html).not.toContain('data-share-mode-default');
80
+ });
81
+
82
+ it('keeps html transparent so the body ground propagates BENEATH the canvas', () => {
83
+ const html = withNeat();
84
+ // Body ground + transparent html is the /s arrangement: the background
85
+ // propagates to the canvas layer, under the z-index:-1 backdrop. An html
86
+ // background (or a bg-background class painting body as an element)
87
+ // would sit above the backdrop and hide it.
88
+ expect(html).toContain('body{background:var(--background)}');
89
+ expect(html).not.toContain('html{background');
90
+ expect(html).not.toContain('<body class="bg-background');
91
+ });
92
+
93
+ it('carries the licence only when set', () => {
94
+ expect(withNeat('k-1')).toContain('data-neat-license="k-1"');
95
+ expect(withNeat()).not.toContain('data-neat-license');
96
+ });
97
+ });
@@ -38,7 +38,10 @@ export function buildAppFrameCsp(origin: string): string {
38
38
  // inside the AppSandbox iframe it composes with the identical attribute.
39
39
  `sandbox allow-scripts; ` +
40
40
  `default-src 'none'; style-src 'unsafe-inline' ${origin}/share-runtime/; ` +
41
- `script-src 'unsafe-inline' ${origin}/app-runtime/; ` +
41
+ // share-runtime scripts joined app-runtime for the Neat backdrop
42
+ // (share-page.js + its lazy WebGL chunk) — same-origin built assets,
43
+ // no new capability.
44
+ `script-src 'unsafe-inline' ${origin}/app-runtime/ ${origin}/share-runtime/; ` +
42
45
  `img-src data: blob: ${origin}; ` +
43
46
  `font-src data: ${origin}/share-runtime/ ${origin}/fonts/; ` +
44
47
  "connect-src 'none'; base-uri 'none'; form-action 'none'"
@@ -185,6 +188,14 @@ export function buildAppFrameHtml(opts: {
185
188
  colorTheme: string | null;
186
189
  /** Viewport frame (iframe IS the viewport) vs card frame (auto-sized). */
187
190
  viewport: boolean;
191
+ /** The brain's saved Neat background spec (canonical encoding), or
192
+ * null/absent for the plain themed ground. Resolved server-side by the
193
+ * frame route — owner frames from the prefs alone, shared frames gated by
194
+ * the shareNeat switch. */
195
+ neatSpec?: string | null;
196
+ /** Neat licence key for watermark removal — same env-sourced value the
197
+ * other surfaces ride. */
198
+ neatLicense?: string | null;
188
199
  }): string {
189
200
  const colorThemeAttr = opts.colorTheme ? ` data-color-theme="${attr(opts.colorTheme)}"` : '';
190
201
  return `<!doctype html>
@@ -205,8 +216,14 @@ export function buildAppFrameHtml(opts: {
205
216
  <style>/* Paint the iframe canvas with the theme background, NOT transparent: a
206
217
  sandboxed (opaque-origin) iframe renders WHITE where it's transparent, so any
207
218
  gap between the app content and the iframe height showed a white strip. With
208
- the themed background, any such gap is invisible (matches the app + host). */
209
- html,body{margin:0;background:var(--background)}#root{padding:0}
219
+ the themed background, any such gap is invisible (matches the app + host).
220
+ The ground lives on BODY with html left transparent — the /s reader's exact
221
+ arrangement, and load-bearing for the Neat backdrop: a body background on a
222
+ transparent html PROPAGATES to the canvas, painted beneath everything
223
+ including the backdrop's z-index:-1 host, while a background on html (or a
224
+ non-propagating body background) paints in the normal layers, above the
225
+ backdrop, and hides it. */
226
+ html,body{margin:0}body{background:var(--background)}#root{padding:0}
210
227
  /* Themed scrollbars for the WHOLE app. The host only styles scrollbars behind an
211
228
  opt-in .scrollbar-thin class, so an app's own scroll containers otherwise fall
212
229
  back to the default wide OS scrollbar with a white/grey track that clashes with
@@ -234,11 +251,27 @@ body{overflow:auto}`
234
251
  .h-screen,.h-dvh,.h-svh,.h-lvh{height:auto!important}`
235
252
  }</style>
236
253
  </head>
237
- <body class="bg-background text-foreground">
238
- <div id="root"></div>
254
+ <body class="text-foreground">
255
+ ${
256
+ /* The brain's saved Neat gradient, rendered INSIDE the frame document —
257
+ the iframe is opaque-origin (sandbox without allow-same-origin), so
258
+ transparency renders WHITE and the host page's backdrop can never show
259
+ through; the only way the background "transfers" into an app is to
260
+ paint it here. Same host-div + share-page.js contract as the /s reader
261
+ (no mode toggle mounts — that requires the reader's
262
+ data-share-mode-default, which frames deliberately lack), and the
263
+ runtime's class observer repaints it when the parent posts a theme
264
+ change. z-index:-1 sits above the html ground (the canvas layer) and
265
+ below all app content. */
266
+ opts.neatSpec
267
+ ? `<div data-neat-spec="${attr(opts.neatSpec)}"${
268
+ opts.neatLicense ? ` data-neat-license="${attr(opts.neatLicense)}"` : ''
269
+ } style="position:fixed;inset:0;z-index:-1;pointer-events:none" aria-hidden="true"></div>\n`
270
+ : ''
271
+ }<div id="root"></div>
239
272
  <script>${ERROR_REPORTER}</script>
240
273
  <script type="module">${opts.bundleCode}</script>
241
274
  <script>${INSPECTOR}</script>
242
- </body>
275
+ ${opts.neatSpec ? `<script type="module" src="/share-runtime/share-page.js"></script>\n` : ''}</body>
243
276
  </html>`;
244
277
  }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { decodeNeatSpec, encodeNeatSpec, neatConfigFromSpec } from './neat-background';
3
+
4
+ const SPEC = { v: 1 as const, seed: 2325271021, tone: 'auto' as const, speed: 2 };
5
+
6
+ describe('decodeNeatSpec', () => {
7
+ it('round-trips the canonical encoding', () => {
8
+ expect(decodeNeatSpec(encodeNeatSpec(SPEC))).toEqual(SPEC);
9
+ });
10
+
11
+ it('rejects garbage as null, never a throw', () => {
12
+ for (const bad of [null, '', '{}', '{"v":2}', 'not json', '{"v":1,"seed":-1}']) {
13
+ expect(decodeNeatSpec(bad)).toBeNull();
14
+ }
15
+ });
16
+ });
17
+
18
+ describe('neatConfigFromSpec — the wash', () => {
19
+ const long = {
20
+ background: '#ffffff',
21
+ primary: '#b85c23',
22
+ accent: '#eeeeee',
23
+ secondary: '#527575',
24
+ };
25
+ // What getComputedStyle actually returns on a deployed build: the compiled
26
+ // stylesheet minifies hex custom properties to shorthand where possible.
27
+ const short = { background: '#fff', primary: '#b85c23', accent: '#eee', secondary: '#527575' };
28
+
29
+ it('produces the IDENTICAL config for shorthand #rgb and #rrggbb tokens', () => {
30
+ // Whole-config equality on purpose: it pins the wash (a failed shorthand
31
+ // parse degraded stops to the raw brand colour) AND the raw pass-throughs
32
+ // (colors[0]/[4] and backgroundColor once carried '#fff' verbatim, which
33
+ // Neat's parseInt-based parser reads as 0x000fff — electric blue).
34
+ expect(neatConfigFromSpec(SPEC, short, 'light')).toEqual(
35
+ neatConfigFromSpec(SPEC, long, 'light'),
36
+ );
37
+ });
38
+
39
+ it('hands the shader only canonical six-digit hex, never shorthand', () => {
40
+ const c = neatConfigFromSpec(SPEC, short, 'light');
41
+ for (const stop of c.colors) expect(stop.color).toMatch(/^#[0-9a-f]{6}$/);
42
+ expect(c.backgroundColor).toBe('#ffffff');
43
+ });
44
+
45
+ it('actually washes the brand colour toward the ground', () => {
46
+ const washed = neatConfigFromSpec(SPEC, short, 'light').colors[1]?.color ?? '';
47
+ // Raw primary (or its 1.08 shade, #c66c39-ish) means the mix silently
48
+ // failed; a washed stop is much lighter than the brand colour.
49
+ expect(washed).not.toBe('#b85c23');
50
+ const mean =
51
+ [1, 3, 5].map((i) => parseInt(washed.slice(i, i + 2), 16)).reduce((s, n) => s + n, 0) / 3;
52
+ expect(mean).toBeGreaterThan(150);
53
+ });
54
+
55
+ it('is deterministic — same seed, same config', () => {
56
+ expect(neatConfigFromSpec(SPEC, long, 'dark')).toEqual(neatConfigFromSpec(SPEC, long, 'dark'));
57
+ });
58
+ });
@@ -109,7 +109,20 @@ function mulberry32(seed: number): () => number {
109
109
  }
110
110
 
111
111
  function hexToRgb(hex: string): [number, number, number] | null {
112
- const digits = /^#?([0-9a-f]{6})$/i.exec(hex.trim())?.[1];
112
+ const t = hex.trim();
113
+ // Shorthand #rgb MUST parse: the compiled stylesheets minify hex custom
114
+ // properties (#ffffff → #fff, #eeeeee → #eee), so this is what
115
+ // getComputedStyle actually returns on deployed builds. Rejecting it made
116
+ // mixHex silently return the RAW brand colour — no wash, a saturated
117
+ // poster instead of a tint — on exactly the themes whose tokens shorten,
118
+ // and only in production (dev CSS is unminified). Light modes were hit
119
+ // hardest because near-white grounds (#ffffff/#eeeeee) all shorten.
120
+ const short = /^#?([0-9a-f]{3})$/i.exec(t)?.[1];
121
+ if (short) {
122
+ const c = (i: number) => parseInt(short.charAt(i) + short.charAt(i), 16);
123
+ return [c(0), c(1), c(2)];
124
+ }
125
+ const digits = /^#?([0-9a-f]{6})$/i.exec(t)?.[1];
113
126
  if (!digits) return null;
114
127
  const n = parseInt(digits, 16);
115
128
  return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
@@ -148,6 +161,18 @@ function shadeHex(hex: string, factor: number): string {
148
161
  ]);
149
162
  }
150
163
 
164
+ /** Canonical #rrggbb for any hex this module can parse; unparseable values
165
+ * pass through untouched. EVERY colour handed to the shader goes through
166
+ * this: Neat's own parser is `parseInt(hex, 16)` on whatever it gets, so a
167
+ * shorthand `#fff` reads as the 24-bit int 0x000fff — rgb(0, 15, 255), an
168
+ * electric blue poster where the page ground should be. That is precisely
169
+ * what deployed light themes produced, because minified stylesheets shorten
170
+ * near-white tokens to #fff/#eee. */
171
+ function normalizeHex(hex: string): string {
172
+ const rgb = hexToRgb(hex);
173
+ return rgb ? rgbToHex(rgb) : hex;
174
+ }
175
+
151
176
  const round1 = (n: number) => Math.round(n * 10) / 10;
152
177
  const round2 = (n: number) => Math.round(n * 100) / 100;
153
178
 
@@ -166,32 +191,40 @@ export function neatConfigFromSpec(
166
191
  const range = (lo: number, hi: number) => lo + rnd() * (hi - lo);
167
192
  const darker = spec.tone === 'darker' || (spec.tone === 'auto' && mode === 'dark');
168
193
 
194
+ // Tokens arrive as whatever getComputedStyle returns — on deployed builds
195
+ // that includes minifier shorthand. Normalized ONCE here, so both our wash
196
+ // math and the raw pass-throughs below hand the shader canonical hex.
197
+ const background = normalizeHex(tokens.background);
198
+ const primary = normalizeHex(tokens.primary);
199
+ const accent = normalizeHex(tokens.accent);
200
+ const secondary = normalizeHex(tokens.secondary);
201
+
169
202
  // A wash: pull the brand colour part-way into the page background, then
170
203
  // nudge the result off the surface in the chosen direction. Enough colour
171
204
  // to read as a real gradient, close enough to the surface that content
172
205
  // sitting on it never fights it.
173
206
  const wash = (hex: string, towardBg: number) =>
174
- shadeHex(mixHex(hex, tokens.background, towardBg), darker ? 0.88 : 1.08);
207
+ shadeHex(mixHex(hex, background, towardBg), darker ? 0.88 : 1.08);
175
208
 
176
209
  return {
177
210
  colors: [
178
- { color: tokens.background, enabled: true, influence: round2(range(0.3, 0.6)) },
211
+ { color: background, enabled: true, influence: round2(range(0.3, 0.6)) },
179
212
  {
180
- color: wash(tokens.primary, round2(range(0.25, 0.5))),
213
+ color: wash(primary, round2(range(0.25, 0.5))),
181
214
  enabled: true,
182
215
  influence: round2(range(0.5, 0.9)),
183
216
  },
184
217
  {
185
- color: wash(tokens.accent, round2(range(0.25, 0.55))),
218
+ color: wash(accent, round2(range(0.25, 0.55))),
186
219
  enabled: true,
187
220
  influence: round2(range(0.45, 0.85)),
188
221
  },
189
222
  {
190
- color: wash(tokens.secondary, round2(range(0.35, 0.6))),
223
+ color: wash(secondary, round2(range(0.35, 0.6))),
191
224
  enabled: true,
192
225
  influence: round2(range(0.35, 0.75)),
193
226
  },
194
- { color: tokens.background, enabled: true, influence: round2(range(0.25, 0.55)) },
227
+ { color: background, enabled: true, influence: round2(range(0.25, 0.55)) },
195
228
  ],
196
229
  speed: spec.speed,
197
230
  horizontalPressure: round1(range(2, 5)),
@@ -210,7 +243,7 @@ export function neatConfigFromSpec(
210
243
  grainIntensity: round2(range(0.02, 0.1)),
211
244
  grainSpeed: 0.3,
212
245
  wireframe: false,
213
- backgroundColor: tokens.background,
246
+ backgroundColor: background,
214
247
  backgroundAlpha: 1,
215
248
  };
216
249
  }