@velumo/renderer-dom 0.1.0-beta.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.
Files changed (58) hide show
  1. package/dist/background-renderer.d.ts +16 -0
  2. package/dist/background-renderer.d.ts.map +1 -0
  3. package/dist/background-renderer.js +41 -0
  4. package/dist/background-renderer.js.map +1 -0
  5. package/dist/camera-adapter.d.ts +16 -0
  6. package/dist/camera-adapter.d.ts.map +1 -0
  7. package/dist/camera-adapter.js +13 -0
  8. package/dist/camera-adapter.js.map +1 -0
  9. package/dist/default-theme.d.ts +5 -0
  10. package/dist/default-theme.d.ts.map +1 -0
  11. package/dist/default-theme.js +579 -0
  12. package/dist/default-theme.js.map +1 -0
  13. package/dist/dom-renderer.d.ts +62 -0
  14. package/dist/dom-renderer.d.ts.map +1 -0
  15. package/dist/dom-renderer.js +306 -0
  16. package/dist/dom-renderer.js.map +1 -0
  17. package/dist/html-sanitize.d.ts +18 -0
  18. package/dist/html-sanitize.d.ts.map +1 -0
  19. package/dist/html-sanitize.js +379 -0
  20. package/dist/html-sanitize.js.map +1 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +10 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/layout-renderer.d.ts +16 -0
  26. package/dist/layout-renderer.d.ts.map +1 -0
  27. package/dist/layout-renderer.js +450 -0
  28. package/dist/layout-renderer.js.map +1 -0
  29. package/dist/slide-thumbnail.d.ts +63 -0
  30. package/dist/slide-thumbnail.d.ts.map +1 -0
  31. package/dist/slide-thumbnail.js +93 -0
  32. package/dist/slide-thumbnail.js.map +1 -0
  33. package/dist/spatial-renderer.d.ts +18 -0
  34. package/dist/spatial-renderer.d.ts.map +1 -0
  35. package/dist/spatial-renderer.js +36 -0
  36. package/dist/spatial-renderer.js.map +1 -0
  37. package/dist/theme-fonts.d.ts +8 -0
  38. package/dist/theme-fonts.d.ts.map +1 -0
  39. package/dist/theme-fonts.js +68 -0
  40. package/dist/theme-fonts.js.map +1 -0
  41. package/dist/theme-tokens.d.ts +3 -0
  42. package/dist/theme-tokens.d.ts.map +1 -0
  43. package/dist/theme-tokens.js +43 -0
  44. package/dist/theme-tokens.js.map +1 -0
  45. package/dist/tsconfig.tsbuildinfo +1 -0
  46. package/package.json +20 -0
  47. package/src/background-renderer.ts +77 -0
  48. package/src/camera-adapter.ts +21 -0
  49. package/src/default-theme.ts +582 -0
  50. package/src/dom-renderer.ts +486 -0
  51. package/src/html-sanitize.ts +446 -0
  52. package/src/index.ts +25 -0
  53. package/src/layout-renderer.ts +721 -0
  54. package/src/slide-thumbnail.ts +146 -0
  55. package/src/spatial-renderer.ts +62 -0
  56. package/src/theme-fonts.ts +75 -0
  57. package/src/theme-tokens.ts +66 -0
  58. package/tsconfig.json +10 -0
@@ -0,0 +1,146 @@
1
+ import type { Manifest } from "@velumo/core";
2
+ import { RuntimeController } from "@velumo/runtime";
3
+ import { DomRenderer, type DomRendererOptions } from "./dom-renderer.js";
4
+
5
+ // Default reference dimensions for a 16:9 slide stage.
6
+ const DEFAULT_REFERENCE = { width: 1280, height: 720 };
7
+
8
+ export interface SlideThumbnailOptions {
9
+ /** The host element that will contain the thumbnail. The HOST is responsible
10
+ * for sizing this element's width; the thumbnail fills it via aspect-ratio. */
11
+ readonly root: {
12
+ readonly ownerDocument: {
13
+ createElement(tagName: string): any;
14
+ createElementNS?(ns: string, tagName: string): any;
15
+ createTextNode(text: string): any;
16
+ readonly head?: any;
17
+ querySelector?(selectors: string): any;
18
+ readonly defaultView?: {
19
+ ResizeObserver?: new (cb: ResizeObserverCallback) => ResizeObserver;
20
+ } | null;
21
+ };
22
+ replaceChildren(...children: any[]): void;
23
+ querySelector(selector: string): any;
24
+ getBoundingClientRect?(): {
25
+ width: number;
26
+ height: number;
27
+ top: number;
28
+ left: number;
29
+ right: number;
30
+ bottom: number;
31
+ };
32
+ };
33
+ readonly manifest: Manifest;
34
+ readonly slideId: string;
35
+ /** Override the canonical reference dimensions (default: 1280x720). */
36
+ readonly reference?: { width: number; height: number };
37
+ /** The runtime registry, so plugin-registered layer renderers (e.g. `fx:*`)
38
+ * render real content in the thumbnail instead of fallback layer-type text. */
39
+ readonly registry?: DomRendererOptions["registry"];
40
+ }
41
+
42
+ export interface SlideThumbnailHandle {
43
+ dispose(): void;
44
+ }
45
+
46
+ /**
47
+ * Mounts a slide thumbnail into `options.root`.
48
+ *
49
+ * DOM structure:
50
+ * root
51
+ * └── box [data-velumo-thumbnail] (overflow:hidden, aspect-ratio:16/9)
52
+ * └── stage [data-velumo-thumbnail-stage] (1280×720, container-type:size)
53
+ * └── surface [data-velumo-renderer="dom"] (DomRenderer root)
54
+ *
55
+ * The HOST sizes box's width. The stage is always 1280px wide (reference.width)
56
+ * so that cqw units resolve canonically. A CSS transform scales the stage to fit
57
+ * the host-sized box (transform-origin: top left).
58
+ *
59
+ * ResizeObserver (when available) keeps the scale updated. A synchronous
60
+ * initial measure is taken at mount time; a zero width falls back to reference.width.
61
+ *
62
+ * The [data-velumo-thumbnail] marker on the box causes the defaultThemeCss rule
63
+ * [data-velumo-thumbnail] [data-velumo-animation] { animation: none !important; }
64
+ * to suppress entry animations, keeping thumbnails visually settled.
65
+ */
66
+ export function mountSlideThumbnail(
67
+ options: SlideThumbnailOptions,
68
+ ): SlideThumbnailHandle {
69
+ const reference = options.reference ?? DEFAULT_REFERENCE;
70
+ const doc = options.root.ownerDocument;
71
+
72
+ // Build DOM: box > stage > surface
73
+ const box = doc.createElement("div");
74
+ box.setAttribute("data-velumo-thumbnail", "");
75
+ box.style.overflow = "hidden";
76
+ box.style.aspectRatio = "16 / 9";
77
+
78
+ const stage = doc.createElement("div");
79
+ stage.setAttribute("data-velumo-thumbnail-stage", "");
80
+ stage.style.width = `${reference.width}px`;
81
+ stage.style.height = `${reference.height}px`;
82
+ stage.style.containerType = "size";
83
+ stage.style.transformOrigin = "top left";
84
+
85
+ const surface = doc.createElement("div");
86
+ // Fill the stage so the renderer root has a DEFINITE 1280x720 size. Without an
87
+ // explicit height the surface shrink-wraps its content, so its `container-type:
88
+ // size` would resolve `cqh` to the content height instead of 720px and the
89
+ // slide's `cqh`-based frame padding would be wrong (gate-3 finding 1).
90
+ surface.style.width = "100%";
91
+ surface.style.height = "100%";
92
+
93
+ stage.appendChild(surface);
94
+ box.appendChild(stage);
95
+ options.root.replaceChildren(box);
96
+
97
+ // Wire up runtime + renderer
98
+ const runtime = new RuntimeController(options.manifest, {
99
+ initialSlideId: options.slideId,
100
+ });
101
+ const renderer = new DomRenderer({
102
+ root: surface,
103
+ manifest: options.manifest,
104
+ runtime,
105
+ registry: options.registry,
106
+ });
107
+ renderer.mount();
108
+
109
+ // Scale helpers
110
+ function applyScale(boxWidth: number): void {
111
+ stage.style.transform = `scale(${boxWidth / reference.width})`;
112
+ }
113
+
114
+ // Synchronous initial measure
115
+ const initialWidth = box.getBoundingClientRect?.()?.width ?? 0;
116
+ applyScale(initialWidth > 0 ? initialWidth : reference.width);
117
+
118
+ // ResizeObserver (observe the BOX — a test asserts this)
119
+ let disposed = false;
120
+ const ROCtor:
121
+ | (new (cb: ResizeObserverCallback) => ResizeObserver)
122
+ | undefined =
123
+ (globalThis as any).ResizeObserver ?? doc.defaultView?.ResizeObserver;
124
+
125
+ let observer: ResizeObserver | undefined;
126
+ if (ROCtor !== undefined) {
127
+ observer = new ROCtor((entries) => {
128
+ if (disposed) return;
129
+ const width = entries[0]?.contentRect?.width;
130
+ if (typeof width === "number") {
131
+ applyScale(width);
132
+ }
133
+ });
134
+ observer.observe(box);
135
+ }
136
+
137
+ return {
138
+ dispose(): void {
139
+ if (disposed) return;
140
+ disposed = true;
141
+ observer?.disconnect();
142
+ renderer.dispose();
143
+ options.root.replaceChildren();
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,62 @@
1
+ import type { CameraEasing, Scene } from "@velumo/core";
2
+ import {
3
+ type LayoutRendererElement,
4
+ type LayoutRendererFactories,
5
+ renderLayoutNode,
6
+ } from "./layout-renderer.js";
7
+
8
+ export interface SpatialWorldElements {
9
+ readonly viewport: LayoutRendererElement;
10
+ readonly world: LayoutRendererElement;
11
+ }
12
+
13
+ /**
14
+ * Build a clipping viewport + an inner world layer for a spatial scene.
15
+ * The world layer carries the scene's `layout` (the world canvas) and is the
16
+ * element the camera transform is applied to.
17
+ */
18
+ export function renderSpatialWorld(
19
+ scene: Scene,
20
+ factories: LayoutRendererFactories,
21
+ ): SpatialWorldElements {
22
+ const viewport = factories.createElement("div");
23
+ viewport.setAttribute("data-velumo-spatial", "viewport");
24
+ factories.setStyleProperty(viewport, "position", "absolute");
25
+ factories.setStyleProperty(viewport, "inset", "0");
26
+ factories.setStyleProperty(viewport, "overflow", "hidden");
27
+
28
+ const world = factories.createElement("div");
29
+ world.setAttribute("data-velumo-spatial", "world");
30
+ factories.setStyleProperty(world, "position", "absolute");
31
+ factories.setStyleProperty(world, "inset", "0");
32
+ // A flex column so a flowing canvas layout (flex: 1) fills the world; the
33
+ // canvas's percent-positioned items then map onto the full world box.
34
+ factories.setStyleProperty(world, "display", "flex");
35
+ factories.setStyleProperty(world, "flex-direction", "column");
36
+ factories.setStyleProperty(world, "transform-origin", "0 0");
37
+ factories.setStyleProperty(world, "will-change", "transform");
38
+
39
+ if (scene.layout !== undefined) {
40
+ world.appendChild(renderLayoutNode(scene.layout, factories));
41
+ }
42
+
43
+ viewport.appendChild(world);
44
+ return { viewport, world };
45
+ }
46
+
47
+ /**
48
+ * Set the CSS transition that animates the next camera move. A zero duration
49
+ * (e.g. reduced motion) disables the transition so the move is an instant cut.
50
+ */
51
+ export function applyCameraTransition(
52
+ world: LayoutRendererElement,
53
+ durationMs: number,
54
+ easing: CameraEasing,
55
+ factories: LayoutRendererFactories,
56
+ ): void {
57
+ factories.setStyleProperty(
58
+ world,
59
+ "transition",
60
+ durationMs <= 0 ? "none" : `transform ${durationMs}ms ${easing}`,
61
+ );
62
+ }
@@ -0,0 +1,75 @@
1
+ import type { ThemeFontFace } from "@velumo/core";
2
+
3
+ // Escape a value for safe interpolation inside a double-quoted CSS string
4
+ // (a `"..."` literal, a `url("...")` descriptor, or a `[attr="..."]` selector
5
+ // value). Backslash and double-quote are escaped; a literal newline becomes the
6
+ // CSS `\A ` escape. `ThemeFontFace`/`Theme.id` are validated only as non-empty
7
+ // strings, so a `"` in a family/source/id must not be able to break out of the
8
+ // string (invalid CSS, a dropped @font-face, or a `querySelector` SyntaxError).
9
+ export function escapeCssString(value: string): string {
10
+ return value.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\A ");
11
+ }
12
+
13
+ // Resolve the `format()` hint for a face's `src` descriptor. An explicit
14
+ // `format` wins; otherwise we sniff `woff2` from a `data:font/woff2` URI or a
15
+ // `.woff2` path (ignoring any query suffix). For anything else we emit no
16
+ // `format()` at all and let the browser sniff the bytes — keeping the contract
17
+ // format-agnostic while defaulting correctly for the self-contained woff2
18
+ // data-URIs the themes ship.
19
+ function resolveFormat(face: ThemeFontFace): string | undefined {
20
+ if (face.format !== undefined) {
21
+ return face.format;
22
+ }
23
+ if (/^data:font\/woff2[;,]/.test(face.source)) {
24
+ return "woff2";
25
+ }
26
+ const pathOnly = face.source.split("?")[0];
27
+ if (/\.woff2$/i.test(pathOnly)) {
28
+ return "woff2";
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ // Compile one `ThemeFontFace` into a single `@font-face` block. The family is
34
+ // always quoted; the source is a single combined descriptor; every optional
35
+ // descriptor is emitted only when present (a string `weight` such as the
36
+ // variable range "100 900" passes through verbatim).
37
+ function compileFontFace(face: ThemeFontFace): string {
38
+ const format = resolveFormat(face);
39
+ // Always quote the url() so sources with spaces or other characters that are
40
+ // invalid in an unquoted url() token (e.g. "/fonts/Bebas Neue.woff2") still
41
+ // parse — a quoted data-URI is equally valid.
42
+ const url = `url("${escapeCssString(face.source)}")`;
43
+ const src =
44
+ format === undefined ? url : `${url} format("${escapeCssString(format)}")`;
45
+
46
+ const lines = [
47
+ ` font-family: "${escapeCssString(face.family)}";`,
48
+ ` src: ${src};`,
49
+ ];
50
+ if (face.weight !== undefined) {
51
+ lines.push(` font-weight: ${face.weight};`);
52
+ }
53
+ if (face.style !== undefined) {
54
+ lines.push(` font-style: ${face.style};`);
55
+ }
56
+ if (face.display !== undefined) {
57
+ lines.push(` font-display: ${face.display};`);
58
+ }
59
+ if (face.unicodeRange !== undefined) {
60
+ lines.push(` unicode-range: ${face.unicodeRange};`);
61
+ }
62
+ if (face.stretch !== undefined) {
63
+ lines.push(` font-stretch: ${face.stretch};`);
64
+ }
65
+
66
+ return `@font-face {\n${lines.join("\n")}\n}`;
67
+ }
68
+
69
+ /**
70
+ * Compile a theme's declared font faces into `@font-face` CSS — one block per
71
+ * face. Pure and DOM-free; the DOM renderer injects the result at mount.
72
+ */
73
+ export function compileFontFaceCss(fonts: readonly ThemeFontFace[]): string {
74
+ return fonts.map(compileFontFace).join("\n\n");
75
+ }
@@ -0,0 +1,66 @@
1
+ import type { ThemeTokens } from "@velumo/core";
2
+
3
+ export function tokensToCssProperties(
4
+ tokens: ThemeTokens | undefined,
5
+ ): Map<string, string> {
6
+ const properties = new Map<string, string>();
7
+
8
+ if (tokens === undefined) {
9
+ return properties;
10
+ }
11
+
12
+ addStringRecord(properties, "color", tokens.colors);
13
+ addNestedTokenRecord(properties, "typography", tokens.typography);
14
+ addStringRecord(properties, "spacing", tokens.spacing);
15
+ addStringRecord(properties, "radius", tokens.radii);
16
+ addStringRecord(properties, "shadow", tokens.shadows);
17
+ addNestedTokenRecord(properties, "motion", tokens.motion);
18
+ addStringRecord(properties, "surface", tokens.surfaces);
19
+ addStringRecord(properties, "text", tokens.text);
20
+
21
+ return properties;
22
+ }
23
+
24
+ function addStringRecord(
25
+ properties: Map<string, string>,
26
+ family: string,
27
+ tokens: Record<string, string | number> | undefined,
28
+ ): void {
29
+ if (tokens === undefined) {
30
+ return;
31
+ }
32
+
33
+ for (const [name, value] of Object.entries(tokens)) {
34
+ properties.set(`--velumo-${family}-${toKebabCase(name)}`, String(value));
35
+ }
36
+ }
37
+
38
+ function addNestedTokenRecord(
39
+ properties: Map<string, string>,
40
+ family: string,
41
+ tokens: Record<string, object> | undefined,
42
+ ): void {
43
+ if (tokens === undefined) {
44
+ return;
45
+ }
46
+
47
+ for (const [name, values] of Object.entries(tokens)) {
48
+ for (const [field, value] of Object.entries(values)) {
49
+ if (typeof value !== "string" && typeof value !== "number") {
50
+ continue;
51
+ }
52
+
53
+ properties.set(
54
+ `--velumo-${family}-${toKebabCase(name)}-${toKebabCase(field)}`,
55
+ String(value),
56
+ );
57
+ }
58
+ }
59
+ }
60
+
61
+ function toKebabCase(value: string): string {
62
+ return value
63
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
64
+ .replace(/[_\s.]+/g, "-")
65
+ .toLowerCase();
66
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "references": [{ "path": "../core" }, { "path": "../runtime" }]
10
+ }