@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 +3 -3
- package/src/app-frame-html.test.ts +46 -0
- package/src/app-frame-html.ts +39 -6
- package/src/neat-background.test.ts +58 -0
- package/src/neat-background.ts +41 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/share-ui",
|
|
3
|
-
"version": "0.232.
|
|
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.
|
|
40
|
-
"@mantle/content-core": "npm:@crossworks/content-core@0.232.
|
|
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="{"v":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
|
+
});
|
package/src/app-frame-html.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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="
|
|
238
|
-
|
|
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
|
+
});
|
package/src/neat-background.ts
CHANGED
|
@@ -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
|
|
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,
|
|
207
|
+
shadeHex(mixHex(hex, background, towardBg), darker ? 0.88 : 1.08);
|
|
175
208
|
|
|
176
209
|
return {
|
|
177
210
|
colors: [
|
|
178
|
-
{ color:
|
|
211
|
+
{ color: background, enabled: true, influence: round2(range(0.3, 0.6)) },
|
|
179
212
|
{
|
|
180
|
-
color: wash(
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
246
|
+
backgroundColor: background,
|
|
214
247
|
backgroundAlpha: 1,
|
|
215
248
|
};
|
|
216
249
|
}
|