create-forma-extension 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dinar Sharafutdinov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # create-forma-extension
2
+
3
+ An npm initializer for Autodesk Forma extensions, with Vite, strict TypeScript and native Autodesk UI.
4
+ The initializer requires Node.js 20 or later and has zero runtime dependencies.
5
+
6
+ ```sh
7
+ npm create forma-extension@latest my-extension
8
+ ```
9
+
10
+ The command prompts for a display name, creates the project and initializes Git without a commit.
11
+ An omitted directory triggers a prompt with `forma-extension` as the default.
12
+ The default display name comes from the directory: `my-extension` becomes `My Extension`.
13
+ The package name is an ASCII slug of the display name.
14
+
15
+ ```sh
16
+ cd my-extension
17
+ npm install
18
+ npm run dev
19
+ ```
20
+
21
+ The fixture preview is available at [http://localhost:5173/?fixture=1](http://localhost:5173/?fixture=1) without a Forma licence.
22
+ Native controls load from Autodesk's CDN and require an internet connection.
23
+ The generated README describes registration in Forma and the floating-panel button configuration.
24
+
25
+ ## Flags
26
+
27
+ `npx create-forma-extension my-extension` provides the same initializer.
28
+ With `npm create`, arguments after `--` are forwarded to the initializer:
29
+
30
+ ```sh
31
+ npm create forma-extension@latest my-extension -- --name "My Extension" --yes --no-git
32
+ ```
33
+
34
+ | Flag | Behaviour |
35
+ | --- | --- |
36
+ | `--name <name>` | Sets the display name without a name prompt. |
37
+ | `--yes` | Accepts defaults for omitted values without prompts. |
38
+ | `--force` | Allows a non-empty directory and overwrites template files; unrelated files and existing Git history remain. |
39
+ | `--no-git` | Skips Git initialization. |
40
+ | `--help` | Prints usage and exits successfully. |
41
+ | `--version` | Prints the initializer version and exits successfully. |
42
+
43
+ Non-empty directories are refused by default, including directories containing only `.git`.
44
+ Symbolic links and incompatible paths at scaffold destinations are refused even with `--force`.
45
+ Noninteractive execution requires `--yes` or an explicit directory and `--name`.
46
+ Expected errors exit with code 1 and a single-line message; successful commands exit with code 0.
47
+
48
+ ## Generated project
49
+
50
+ | Path | Contents |
51
+ | --- | --- |
52
+ | `package.json` | Private ESM package, display name, pinned SDK and development dependencies, development/typecheck/build scripts. |
53
+ | `index.html`, `src/` | Native Autodesk controls, responsive panel layouts, proposal and footprint adapters, synthetic preview, loading/empty/error states. |
54
+ | `forma/buttons.yaml` | Floating-panel button registration. |
55
+ | `tsconfig.json`, `vite.config.ts` | Strict TypeScript and Vite on port 5173. |
56
+ | `.editorconfig`, `.gitattributes`, `.gitignore` | Editor, line-ending and Git defaults. |
57
+ | `docs/assets/logo.svg`, `README.md` | Original logo, development and registration instructions, upstream MIT notice. |
58
+
59
+ The generated project contains no upstream GitHub workflows, contribution/security documents, rename script, screenshots or lockfile.
60
+ `npm install` creates the project's own lockfile.
61
+ The display name is substituted with escaping appropriate to JSON, HTML and Markdown.
62
+ The README records the package name and generation year.
63
+ The UI reads its name from the HTML title.
64
+
65
+ The source is [autodesk-forma-extension-template](https://github.com/sharafutdinovdi/autodesk-forma-extension-template).
66
+ [forma-zoning-check](https://github.com/sharafutdinovdi/forma-zoning-check) is a full extension with geometry calculations, panel synchronization and overlays.
67
+
68
+ ## Template maintenance
69
+
70
+ `template/` is a real, checked-in directory bundled with the npm package.
71
+ The initializer runs entirely from those files and does not fetch the source repository.
72
+
73
+ ```sh
74
+ npm run build
75
+ ```
76
+
77
+ The build runs `scripts/sync-template.mjs` against `../autodesk-forma-extension-template`.
78
+ The sync is idempotent and fails if that sibling repository or a required source file is missing.
79
+ It copies an explicit allowlist, parameterizes names and trims the generated README.
80
+ Contributors edit the upstream source and regenerate `template/`; manual changes to the generated directory are overwritten.
81
+ Packaging uses the checked-in template and requires no sibling checkout.
82
+
83
+ The template stores `.gitignore` as `_gitignore`; the initializer restores `.gitignore` in the generated project.
84
+ npm excludes `.gitignore` during packing; other dotfiles in `template/` are included explicitly through the package's `files` directory entry.
85
+ See [npm's package file rules](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#files).
86
+
87
+ ## Licence
88
+
89
+ [MIT](LICENSE) · Copyright (c) 2026 Dinar Sharafutdinov.
90
+ This community initializer is not an Autodesk product.
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { cp, lstat, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const packageRoot = fileURLToPath(new URL("../", import.meta.url));
9
+ const templateRoot = join(packageRoot, "template");
10
+ const usage = `Usage: npm create forma-extension@latest [dir] [-- options]
11
+ npx create-forma-extension [dir] [options]
12
+
13
+ Create an Autodesk Forma extension. Requires Node.js 20 or later.
14
+
15
+ Options:
16
+ --name <name> Display name (package name is its ASCII slug)
17
+ --yes Accept defaults without prompting
18
+ --force Overwrite template files in a non-empty directory
19
+ --no-git Skip git initialization
20
+ --help Print this help
21
+ --version Print the initializer version
22
+
23
+ Default directory: forma-extension
24
+ --force preserves unrelated files and existing git history.
25
+ `;
26
+
27
+ function parseArgs(args) {
28
+ const options = {};
29
+ for (let index = 0; index < args.length; index++) {
30
+ const arg = args[index];
31
+ if (arg === "--") continue;
32
+ if (["--yes", "--force", "--no-git", "--help", "--version"].includes(arg)) {
33
+ options[arg.slice(2)] = true;
34
+ } else if (arg === "--name" || arg.startsWith("--name=")) {
35
+ const value = arg === "--name" ? args[++index] : arg.slice(7);
36
+ if (!value || value.startsWith("--")) throw new Error("--name requires a display name.");
37
+ options.name = value;
38
+ } else if (arg.startsWith("-")) {
39
+ throw new Error(`Unknown option: ${arg}. Use --help for usage.`);
40
+ } else if (options.dir !== undefined) {
41
+ throw new Error("Only one target directory is allowed.");
42
+ } else {
43
+ options.dir = arg;
44
+ }
45
+ }
46
+ return options;
47
+ }
48
+
49
+ async function statIfExists(file) {
50
+ try {
51
+ return await lstat(file);
52
+ } catch (error) {
53
+ if (error.code === "ENOENT") return null;
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ function displayNameFor(directory) {
59
+ return basename(directory).replace(/[-_]+/g, " ").replace(/\b\w/g, letter => letter.toUpperCase());
60
+ }
61
+
62
+ function slugFor(name) {
63
+ const slug = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase()
64
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
65
+ if (!slug || slug.length > 214 || ["node_modules", "favicon.ico"].includes(slug)) {
66
+ throw new Error("The display name must produce a package name of 1–214 ASCII letters, digits or hyphens.");
67
+ }
68
+ return slug;
69
+ }
70
+
71
+ function escapeHtml(value) {
72
+ return value.replace(/[&<>"']/g, character => ({
73
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
74
+ })[character]);
75
+ }
76
+
77
+ function substitute(content, values) {
78
+ return content.replace(/__(DISPLAY_NAME|PACKAGE_NAME|YEAR)__/g, (_, key) => values[key]);
79
+ }
80
+
81
+ async function filesUnder(directory) {
82
+ const files = [];
83
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
84
+ const file = join(directory, entry.name);
85
+ if (entry.isDirectory()) files.push(...await filesUnder(file));
86
+ else if (entry.isFile()) files.push(file);
87
+ else throw new Error(`Template contains an unsupported entry: ${file}.`);
88
+ }
89
+ return files;
90
+ }
91
+
92
+ async function checkDestination(file, directory, target) {
93
+ const parent = dirname(file);
94
+ if (file !== target) await checkDestination(parent, true, target);
95
+ const stat = await statIfExists(file);
96
+ if (stat && (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()))) {
97
+ throw new Error(`Cannot overwrite a symbolic link or incompatible path: ${file}.`);
98
+ }
99
+ }
100
+
101
+ let readline;
102
+
103
+ async function prompt(label, fallback) {
104
+ if (!process.stdin.isTTY) throw new Error("Interactive input requires a terminal; use --yes or provide a directory and --name.");
105
+ if (!readline) readline = createInterface({ input: process.stdin, output: process.stdout });
106
+ const controller = new AbortController();
107
+ const cancel = () => controller.abort();
108
+ readline.once("SIGINT", cancel);
109
+ readline.once("close", cancel);
110
+ try {
111
+ return (await readline.question(`${label} (${fallback}): `, { signal: controller.signal })).trim() || fallback;
112
+ } catch (error) {
113
+ if (error.name === "AbortError") throw new Error("Cancelled.");
114
+ throw error;
115
+ } finally {
116
+ readline.off("SIGINT", cancel);
117
+ readline.off("close", cancel);
118
+ }
119
+ }
120
+
121
+ async function main() {
122
+ const options = parseArgs(process.argv.slice(2));
123
+ if (options.help) return process.stdout.write(usage);
124
+ if (options.version) {
125
+ const pkg = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
126
+ return console.log(pkg.version);
127
+ }
128
+ const directory = options.dir ?? (options.yes ? "forma-extension" : await prompt("Project directory", "forma-extension"));
129
+ if (!directory.trim() || /[\x00-\x1f\x7f]/.test(directory)) throw new Error("Provide a non-empty directory without control characters.");
130
+ const target = resolve(directory);
131
+ if (packageRoot.startsWith(target + sep) || target === resolve(packageRoot) || target.startsWith(templateRoot + sep) || target === templateRoot) {
132
+ throw new Error("The target directory must not overwrite the initializer or its template.");
133
+ }
134
+ await checkDestination(target, true, target);
135
+ const stat = await statIfExists(target);
136
+ if (stat && (await readdir(target)).length && !options.force) {
137
+ throw new Error(`Directory is not empty: ${directory}. Use --force to overwrite template files.`);
138
+ }
139
+ const fallbackName = displayNameFor(target);
140
+ const name = (options.name ?? (options.yes ? fallbackName : await prompt("Display name", fallbackName))).trim();
141
+ readline?.close();
142
+ if (!name || /[\x00-\x1f\x7f]/.test(name)) throw new Error("The display name must not be empty or contain control characters.");
143
+ const values = { DISPLAY_NAME: name, PACKAGE_NAME: slugFor(name), YEAR: String(new Date().getFullYear()) };
144
+ const files = await filesUnder(templateRoot);
145
+ for (const file of files) {
146
+ const local = relative(templateRoot, file);
147
+ await checkDestination(join(target, local === "_gitignore" ? ".gitignore" : local), false, target);
148
+ }
149
+ await checkDestination(join(target, "_gitignore"), false, target);
150
+ if (await statIfExists(join(target, "_gitignore"))) throw new Error("The target contains a reserved _gitignore file; move it before scaffolding.");
151
+ if (!options["no-git"]) {
152
+ try {
153
+ execFileSync("git", ["--version"], { stdio: "ignore" });
154
+ } catch {
155
+ throw new Error("Git is unavailable; install Git or use --no-git.");
156
+ }
157
+ }
158
+ await mkdir(target, { recursive: true });
159
+ await cp(templateRoot, target, { recursive: true, force: true });
160
+ await rename(join(target, "_gitignore"), join(target, ".gitignore"));
161
+ for (const file of files) {
162
+ const local = relative(templateRoot, file);
163
+ if (!["package.json", "index.html", "README.md"].includes(local) && !local.startsWith(`src${sep}`)) continue;
164
+ const destination = join(target, local);
165
+ const content = await readFile(destination, "utf8");
166
+ let escapedName = name;
167
+ if (local === "package.json") escapedName = JSON.stringify(name).slice(1, -1);
168
+ else if (local.endsWith(".html")) escapedName = escapeHtml(name);
169
+ else if (local === "README.md") escapedName = escapeHtml(name).replace(/[\\`*_{}\[\]()#+.!|~-]/g, "\\$&");
170
+ else if (/\.[cm]?[jt]sx?$/.test(local)) escapedName = JSON.stringify(name).slice(1, -1).replace(/['`$]/g, "\\$&");
171
+ await writeFile(destination, substitute(content, { ...values, DISPLAY_NAME: escapedName }));
172
+ }
173
+ if (!options["no-git"]) {
174
+ try {
175
+ execFileSync("git", ["init", "--quiet"], { cwd: target, stdio: "pipe" });
176
+ } catch {
177
+ throw new Error(`Project created at ${directory}, but git init failed; run git init there manually.`);
178
+ }
179
+ }
180
+ const cdTarget = /^[a-zA-Z0-9_./-]+$/.test(directory) ? directory : `'${directory.replaceAll("'", "'\\''")}'`;
181
+ console.log(`Created ${name} in ${directory}.
182
+
183
+ Next steps:
184
+ cd ${cdTarget}
185
+ npm install
186
+ npm run dev
187
+
188
+ Fixture: http://localhost:5173/?fixture=1
189
+ Forma: Create an extension, allowlist your project, set RIGHT_MENU_ANALYSIS_PANEL to http://localhost:5173/, paste forma/buttons.yaml into Buttons, then save and add it to the project (see README.md).`);
190
+ }
191
+
192
+ try {
193
+ await main();
194
+ } catch (error) {
195
+ console.error(`Error: ${String(error.message ?? error).replace(/[\r\n\x00-\x1f\x7f]+/g, " ")}`);
196
+ process.exitCode = 1;
197
+ } finally {
198
+ readline?.close();
199
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "create-forma-extension",
3
+ "version": "0.1.0",
4
+ "description": "Create an Autodesk Forma extension with Vite, TypeScript and native Autodesk UI.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Dinar Sharafutdinov",
8
+ "engines": { "node": ">=20" },
9
+ "bin": { "create-forma-extension": "bin/create-forma-extension.mjs" },
10
+ "files": ["bin/", "template/"],
11
+ "scripts": {
12
+ "build": "node scripts/sync-template.mjs",
13
+ "sync-template": "node scripts/sync-template.mjs"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/sharafutdinovdi/create-forma-extension.git"
18
+ },
19
+ "bugs": { "url": "https://github.com/sharafutdinovdi/create-forma-extension/issues" },
20
+ "homepage": "https://github.com/sharafutdinovdi/create-forma-extension#readme",
21
+ "keywords": ["autodesk", "forma", "extension", "create", "vite", "typescript"]
22
+ }
@@ -0,0 +1,12 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ indent_style = space
7
+ indent_size = 2
8
+ insert_final_newline = true
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.md]
12
+ trim_trailing_whitespace = false
@@ -0,0 +1,2 @@
1
+ * text=auto eol=lf
2
+ *.png binary
@@ -0,0 +1,107 @@
1
+ <p align="center">
2
+ <img src="docs/assets/logo.svg" alt="Extension template logo" width="80" height="80">
3
+ </p>
4
+
5
+ # __DISPLAY_NAME__
6
+
7
+ A Vite and TypeScript starter for Forma Site Design extensions with native Autodesk UI.
8
+
9
+ Package: `__PACKAGE_NAME__`.
10
+ Generated in __YEAR__ with [create-forma-extension](https://github.com/sharafutdinovdi/create-forma-extension).
11
+
12
+ ## Development
13
+
14
+ Requires Node.js 20 or later.
15
+
16
+ ```sh
17
+ npm install
18
+ npm run dev
19
+ ```
20
+
21
+ The app runs at **http://localhost:5173**. Vite fails if that port is occupied.
22
+ Outside Forma, open [the synthetic preview](http://localhost:5173/?fixture=1).
23
+ It needs no Forma licence and never loads the SDK, including inside an iframe.
24
+ The native UI still needs an internet connection to Autodesk's CDN.
25
+
26
+ ```sh
27
+ npm run typecheck
28
+ npm run build
29
+ ```
30
+
31
+ The production bundle is written to `dist/`.
32
+
33
+ ## Register it in Forma
34
+
35
+ Use a Forma Site Design project you can edit, in a hub where you have Design access.
36
+ These setup steps reflect the source project's September 2026 observations; current form choices and localhost policy remain unverified in other projects.
37
+
38
+ 1. Open **Extension menu → Add extension → settings (gear) → Create extension**.
39
+ 2. Set **Name**. Choose **Myself only** as Owner for personal development; this observed flow needs no APS application.
40
+ 3. In **Who are allowed**, allowlist your project's `pro_…` authcontext or ACC project ID. Projects outside the allowlist will not show it in **Add extension**.
41
+ 4. Fill **Feedback link** and **Help link** with working URLs. Under **Integration → Embedded views**, select **RIGHT_MENU_ANALYSIS_PANEL** and enter `http://localhost:5173/`.
42
+ 5. Paste [forma/buttons.yaml](forma/buttons.yaml) into **Integration → Buttons**:
43
+
44
+ ```yaml
45
+ - label: Open full panel
46
+ actions:
47
+ click:
48
+ type: OPEN_FLOATING_PANEL
49
+ url: http://localhost:5173/
50
+ preferredSize:
51
+ width: 440
52
+ height: 720
53
+ ```
54
+
55
+ 6. Fill Presentation's **Provider**, **Description** and **Text to show**. Save, reopen settings to confirm persistence, then add the extension to the project.
56
+
57
+ `OPEN_FLOATING_PANEL` is a button action. Both placements use the same bundle and URL.
58
+ Below 300 px, the app shows a compact summary. At 300 px and wider, it shows Summary and Controls tabs.
59
+ Select **Open full panel** in the toolbar for the floating view.
60
+
61
+ ## What you get
62
+
63
+ - Vite and strict TypeScript. SDK **0.96.0** is the only direct runtime npm dependency.
64
+ - Autodesk [base.css](https://app.autodeskforma.eu/design-system/v2/forma/styles/base.css), Artifakt type and CDN Weave tabs, select, primary button and inline error banner. No Weave npm package.
65
+ - Local 4/8/16 px spacing, 24 px controls and 11/12 px type roles, accounting for the Design System's 10 px root.
66
+ - Two tabs, a metric row, a working building-scope select and a locale-safe decimal input. The example limit demonstrates input only; it does not filter buildings.
67
+ - Compact right-panel and full floating layouts. Each view reads independently; select Refresh after proposal edits. There is no shared mutable state or overlay in this starter.
68
+ - `src/forma.ts`: persisted proposal reads, singular `building` and `site_limit` paths, deduplicated building counts and base-group classification.
69
+ - An on-demand `readFootprint(path, snapshot)` adapter: graph and floor representations → direct context footprint → complete child footprints → XY triangles → last-resort direct native footprint. It checks the revision and retains failed-provider diagnostics.
70
+ - Ready, loading, actionable empty and retryable error states. Use `?fixture=1&state=empty`, `state=loading` or `state=error`; Retry/Refresh returns the fixture to ready.
71
+
72
+
73
+ The footprint adapter returns a set union of polygon parts; overlapping parts are not dissolved boundaries and their areas must not be summed.
74
+ The SDK uses proposal APIs deprecated in favour of UDM.
75
+ Fixture checks do not verify registration or live geometry; verify both panel placements in a Forma project before shipping.
76
+
77
+ ## References
78
+
79
+ Based on [autodesk-forma-extension-template](https://github.com/sharafutdinovdi/autodesk-forma-extension-template).
80
+ See [forma-zoning-check](https://github.com/sharafutdinovdi/forma-zoning-check) for a full extension with geometry calculations, cross-panel synchronization and overlays.
81
+
82
+ ## Licence
83
+
84
+ The starter code and original logo retain the upstream MIT licence below.
85
+ This community project is not an Autodesk product.
86
+
87
+ MIT License
88
+
89
+ Copyright (c) 2026 Dinar Sharafutdinov
90
+
91
+ Permission is hereby granted, free of charge, to any person obtaining a copy
92
+ of this software and associated documentation files (the "Software"), to deal
93
+ in the Software without restriction, including without limitation the rights
94
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
95
+ copies of the Software, and to permit persons to whom the Software is
96
+ furnished to do so, subject to the following conditions:
97
+
98
+ The above copyright notice and this permission notice shall be included in all
99
+ copies or substantial portions of the Software.
100
+
101
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
102
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
103
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
104
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
105
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
106
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
107
+ SOFTWARE.
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ *.local
7
+ .DS_Store
8
+ *.tsbuildinfo
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#3c3c3c" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
2
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1"/>
3
+ <path d="M1.5 6h13"/>
4
+ <path d="M4 6V3.5"/>
5
+ <path d="M4 8.5h6M4 11h4" stroke="#0696d7"/>
6
+ </svg>
@@ -0,0 +1,8 @@
1
+ - label: Open full panel
2
+ actions:
3
+ click:
4
+ type: OPEN_FLOATING_PANEL
5
+ url: http://localhost:5173/
6
+ preferredSize:
7
+ width: 440
8
+ height: 720
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en-US">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light" />
7
+ <link rel="icon" href="/docs/assets/logo.svg" type="image/svg+xml" />
8
+ <link rel="stylesheet" href="https://app.autodeskforma.eu/design-system/v2/forma/styles/base.css" />
9
+ <script type="module" src="https://app.autodeskforma.eu/design-system/v2/weave/components/tab/weave-tab.js"></script>
10
+ <script type="module" src="https://app.autodeskforma.eu/design-system/v2/weave/components/button/weave-button.js"></script>
11
+ <script type="module" src="https://app.autodeskforma.eu/design-system/v2/weave/components/dropdown/weave-select.js"></script>
12
+ <script type="module" src="https://app.autodeskforma.eu/design-system/v2/weave/components/banner/weave-banner.js"></script>
13
+ <title>__DISPLAY_NAME__</title>
14
+ </head>
15
+ <body>
16
+ <main aria-label="__DISPLAY_NAME__"></main>
17
+ <script type="module" src="/src/main.ts"></script>
18
+ </body>
19
+ </html>
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "__PACKAGE_NAME__",
3
+ "displayName": "__DISPLAY_NAME__",
4
+ "version": "0.1.0",
5
+ "private": true,
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "scripts": {
12
+ "dev": "vite",
13
+ "typecheck": "tsc --noEmit",
14
+ "build": "vite build"
15
+ },
16
+ "dependencies": {
17
+ "forma-embedded-view-sdk": "0.96.0"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "5.9.3",
21
+ "vite": "6.4.3"
22
+ }
23
+ }
@@ -0,0 +1,17 @@
1
+ import type { ProposalSnapshot } from "./forma";
2
+
3
+ export type ViewState = "ready" | "empty" | "loading" | "error";
4
+
5
+ export function fixtureData(empty = false): ProposalSnapshot {
6
+ return {
7
+ rootUrn: "fixture-root",
8
+ proposalId: "fixture-proposal",
9
+ name: "Example proposal",
10
+ buildingPaths: empty ? [] : ["root/proposal-a", "root/proposal-b", "root/base/context"],
11
+ siteLimitPaths: empty ? [] : ["root/site"],
12
+ buildings: empty ? [] : ["proposal", "proposal", "existing"].map((kind, index) => ({
13
+ path: index === 2 ? "root/base/context" : `root/proposal-${index === 0 ? "a" : "b"}`,
14
+ kind: kind as "proposal" | "existing",
15
+ })),
16
+ };
17
+ }
@@ -0,0 +1,211 @@
1
+ import { Forma } from "forma-embedded-view-sdk/auto";
2
+
3
+ type Tree = Awaited<ReturnType<typeof Forma.elements.get>>;
4
+ type RootUrn = Awaited<ReturnType<typeof Forma.proposal.getRootUrn>>;
5
+ export type BuildingKind = "existing" | "proposal";
6
+ export type Point = [number, number];
7
+ export type Polygon = Point[][];
8
+ export interface ProposalSnapshot {
9
+ rootUrn: string;
10
+ proposalId: string;
11
+ name: string;
12
+ buildingPaths: string[];
13
+ siteLimitPaths: string[];
14
+ buildings: { path: string; kind: BuildingKind }[];
15
+ }
16
+ export interface Footprint {
17
+ // Set union of polygons (outer ring, then holes); parts may overlap, so never sum their areas.
18
+ operation: "union";
19
+ parts: Polygon[];
20
+ source: "graphBuilding" | "grossFloorAreaPolygons" | "footprint" | "children" | "triangles";
21
+ }
22
+ export interface FootprintResult {
23
+ footprint: Footprint | null;
24
+ attempts: { provider: string; error?: string }[];
25
+ }
26
+
27
+ export function buildingClassifier(tree: Tree) {
28
+ const root = tree.element;
29
+ const elements: Tree["elements"] = { ...tree.elements, [root.urn]: root };
30
+ const pending = new Map<string, Promise<void>>();
31
+ return async (path: string): Promise<BuildingKind> => {
32
+ const keys = path.split("/").slice(1, -1);
33
+ const base = /:group:[^:]+:base:/;
34
+ // The building's own basic/basicbuilding URN does not identify its source.
35
+ if (base.test(root.urn) || keys.some(key => root.properties?.flags?.[key]?.base === true)) return "existing";
36
+ let parent = root;
37
+ for (const key of keys) {
38
+ const child = parent.children?.find(item => item.key === key);
39
+ if (!child) throw new Error(`Cannot resolve building ancestry: ${path}`);
40
+ if (base.test(child.urn)) return "existing";
41
+ if (!elements[child.urn]) {
42
+ if (!pending.has(child.urn)) pending.set(child.urn, Forma.elements.get({ urn: child.urn }).then(fetched => {
43
+ Object.assign(elements, fetched.elements, { [fetched.element.urn]: fetched.element });
44
+ }));
45
+ await pending.get(child.urn);
46
+ }
47
+ parent = elements[child.urn];
48
+ }
49
+ return "proposal";
50
+ };
51
+ }
52
+
53
+ async function assertRevision(rootUrn: string, proposalId: string) {
54
+ const [root, id] = await Promise.all([Forma.proposal.getRootUrn(), Forma.proposal.getId()]);
55
+ if (root !== rootUrn || id !== proposalId) throw new Error("Proposal changed while reading; refresh");
56
+ }
57
+
58
+ export async function readProposal(): Promise<ProposalSnapshot> {
59
+ // These proposal calls are verified in 0.96.0; that SDK deprecates them in favour of UDM.
60
+ await Forma.proposal.awaitProposalPersisted();
61
+ const [rootUrn, proposalId] = await Promise.all([Forma.proposal.getRootUrn(), Forma.proposal.getId()]);
62
+ const [tree, paths, sitePaths] = await Promise.all([
63
+ Forma.elements.get({ urn: rootUrn }),
64
+ Forma.geometry.getPathsByCategory({ category: "building", urn: rootUrn }),
65
+ Forma.geometry.getPathsByCategory({ category: "site_limit", urn: rootUrn }),
66
+ ]);
67
+ const unique = [...new Set(paths)];
68
+ // Nested category-building paths are parts of a counted parent, not additional buildings.
69
+ const buildingPaths = unique.filter(path => !unique.some(parent => path.startsWith(`${parent}/`)));
70
+ const classify = buildingClassifier(tree);
71
+ const buildings = await Promise.all(buildingPaths.map(async path => ({ path, kind: await classify(path) })));
72
+ await assertRevision(rootUrn, proposalId);
73
+ return {
74
+ rootUrn, proposalId, buildingPaths, buildings,
75
+ siteLimitPaths: [...new Set(sitePaths)],
76
+ name: typeof tree.element.properties?.name === "string" ? tree.element.properties.name : proposalId,
77
+ };
78
+ }
79
+
80
+ function ring(points: readonly (readonly number[])[]): Point[] {
81
+ const result = points.map(([x, y]): Point => {
82
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Non-finite footprint coordinate");
83
+ return [x, y];
84
+ });
85
+ if (result.length > 1 && result[0][0] === result.at(-1)![0] && result[0][1] === result.at(-1)![1]) result.pop();
86
+ const cross = (a: Point, b: Point, c: Point) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
87
+ const area = result.reduce((sum, point, index) => sum + cross(result[0], point, result[(index + 1) % result.length]), 0);
88
+ if (result.length < 3 || Math.abs(area) < 1e-8) throw new Error("Empty or degenerate footprint ring");
89
+ // Reject self-crossing boundaries rather than inventing a hull for invalid geometry.
90
+ const on = (a: Point, b: Point, p: Point) => Math.abs(cross(a, b, p)) < 1e-8 &&
91
+ p[0] >= Math.min(a[0], b[0]) && p[0] <= Math.max(a[0], b[0]) && p[1] >= Math.min(a[1], b[1]) && p[1] <= Math.max(a[1], b[1]);
92
+ for (let i = 0; i < result.length; i++) {
93
+ for (let j = i + 2; j < result.length; j++) {
94
+ if (i === 0 && j === result.length - 1) continue;
95
+ const a = result[i], b = result[(i + 1) % result.length], c = result[j], d = result[(j + 1) % result.length];
96
+ if ((cross(a, b, c) * cross(a, b, d) < 0 && cross(c, d, a) * cross(c, d, b) < 0) ||
97
+ on(a, b, c) || on(a, b, d) || on(c, d, a) || on(c, d, b)) throw new Error("Self-intersecting footprint ring");
98
+ }
99
+ }
100
+ return result;
101
+ }
102
+
103
+ export async function readFootprint(path: string, snapshot: Pick<ProposalSnapshot, "rootUrn" | "proposalId">): Promise<FootprintResult> {
104
+ const { rootUrn, proposalId } = snapshot;
105
+ await assertRevision(rootUrn, proposalId);
106
+ const { element } = await Forma.elements.getByPath({ path, rootUrn: rootUrn as RootUrn });
107
+ const attempts: FootprintResult["attempts"] = [];
108
+ const attempt = async (provider: string, read: () => Promise<Polygon[]>): Promise<Polygon[] | null> => {
109
+ try {
110
+ const parts = await read();
111
+ if (!parts.length || parts.some(polygon => !polygon.length)) throw new Error("No footprint polygons");
112
+ attempts.push({ provider });
113
+ return parts;
114
+ } catch (error) {
115
+ attempts.push({ provider, error: error instanceof Error ? error.message : String(error) });
116
+ return null;
117
+ }
118
+ };
119
+ const direct = (target: string) => attempt(`getFootprint:${target}`, async () => {
120
+ const value = await Forma.geometry.getFootprint({ path: target, urn: rootUrn });
121
+ // SDK footprint coordinates are one flat XY ring, unlike GeoJSON Polygon coordinates.
122
+ if (value?.type !== "Polygon") throw new Error(`getFootprint returned ${value === undefined ? "undefined" : "no Polygon"}`);
123
+ return [[ring(value.coordinates)]];
124
+ });
125
+ let transform: number[] | undefined;
126
+ const worldPolygon = async (polygon: Polygon): Promise<Polygon> => {
127
+ // Representations are element-local; the transform API cannot pin a root, so recheck below.
128
+ transform ??= (await Forma.elements.getWorldTransform({ path })).transform;
129
+ const t = transform;
130
+ if (t.length !== 16 || t.some(n => !Number.isFinite(n)) ||
131
+ [2, 3, 6, 7, 8, 9, 11].some(i => Math.abs(t[i]) > 1e-8) || t[10] <= 0 || Math.abs(t[15] - 1) > 1e-8) {
132
+ throw new Error("Invalid or tilted floor transform");
133
+ }
134
+ return polygon.map(points => ring(points.map(([x, y]) => [t[0] * x + t[4] * y + t[12], t[1] * x + t[5] * y + t[13]])));
135
+ };
136
+ const native = /:basicbuilding:/.test(element.urn);
137
+ let parts: Polygon[] | null = null;
138
+ let source: Footprint["source"] = "graphBuilding";
139
+ if (native || element.representations?.graphBuilding) {
140
+ parts = await attempt(source, async () => {
141
+ const graph = await Forma.elements.representations.graphBuilding({ urn: element.urn });
142
+ if (!graph?.data.levels.length) throw new Error("graphBuilding returned no levels");
143
+ const polygons: Polygon[] = [];
144
+ for (const level of graph.data.levels) {
145
+ if (!Number.isFinite(level.height) || level.height <= 0 || !level.spaces.length) throw new Error("Invalid graph level");
146
+ const surfaces = new Map(level.surfaces.map(surface => [surface.id, surface]));
147
+ // Graph points are indexed, not an ordered ring; reconstruct directed surface loops.
148
+ const loop = (edges: typeof level.spaces[number]["outerLoop"]): Point[] => {
149
+ const segments = edges.map(edge => {
150
+ const surface = surfaces.get(edge.surfaceId);
151
+ if (!surface) throw new Error("Missing graph surface");
152
+ return edge.directionAToB ? [surface.pointA, surface.pointB] : [surface.pointB, surface.pointA];
153
+ });
154
+ return segments.map(([start, end], i) => {
155
+ if (end !== segments[(i + 1) % segments.length][0] || !level.points[start]) throw new Error("Disconnected graph loop");
156
+ return level.points[start];
157
+ });
158
+ };
159
+ for (const space of level.spaces) polygons.push(await worldPolygon([loop(space.outerLoop), ...(space.innerLoops ?? []).map(loop)]));
160
+ }
161
+ return polygons;
162
+ });
163
+ }
164
+ if (!parts && (native || element.representations?.grossFloorAreaPolygons)) {
165
+ source = "grossFloorAreaPolygons";
166
+ parts = await attempt(source, async () => {
167
+ const floors = await Forma.elements.representations.grossFloorAreaPolygons({ urn: element.urn });
168
+ if (!floors?.data.length) throw new Error("grossFloorAreaPolygons returned no floors");
169
+ const polygons: Polygon[] = [];
170
+ for (const floor of floors.data) {
171
+ if (!Number.isFinite(floor.elevation)) throw new Error("Invalid floor elevation");
172
+ polygons.push(await worldPolygon(floor.grossFloorPolygon));
173
+ }
174
+ return polygons;
175
+ });
176
+ }
177
+ // Context/basic elements have readable direct footprints; authored basicbuilding often does not.
178
+ if (!parts && !native) { source = "footprint"; parts = await direct(path); }
179
+ if (!parts && element.children?.length) {
180
+ source = "children";
181
+ parts = await attempt(source, async () => {
182
+ const polygons: Polygon[] = [];
183
+ let complete = true;
184
+ for (const child of element.children!) {
185
+ // Child keys are path segments; a partial set must never look like a complete footprint.
186
+ const childParts = await direct(`${path}/${child.key}`);
187
+ if (childParts) polygons.push(...childParts); else complete = false;
188
+ }
189
+ if (!complete) throw new Error("Some child footprints are unavailable");
190
+ return polygons;
191
+ });
192
+ }
193
+ if (!parts) {
194
+ source = "triangles";
195
+ parts = await attempt(source, async () => {
196
+ const vertices = await Forma.geometry.getTriangles({ path, urn: rootUrn });
197
+ if (!vertices.length || vertices.length % 9 || vertices.some(n => !Number.isFinite(n))) throw new Error("Invalid or empty triangle array");
198
+ const polygons: Polygon[] = [];
199
+ for (let i = 0; i < vertices.length; i += 9) {
200
+ const a: Point = [vertices[i], vertices[i + 1]], b: Point = [vertices[i + 3], vertices[i + 4]], c: Point = [vertices[i + 6], vertices[i + 7]];
201
+ // Vertical faces have zero XY area; retain all other parts, including disconnected ones.
202
+ if (Math.abs((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) >= 1e-8) polygons.push([[a, b, c]]);
203
+ }
204
+ return polygons;
205
+ });
206
+ }
207
+ // Preserve a readable native footprint if every preferred provider failed.
208
+ if (!parts && native) { source = "footprint"; parts = await direct(path); }
209
+ await assertRevision(rootUrn, proposalId);
210
+ return { footprint: parts ? { operation: "union", parts, source } : null, attempts };
211
+ }
@@ -0,0 +1,51 @@
1
+ import "./styles.css";
2
+ import { fixtureData } from "./fixture";
3
+ import { createApp } from "./ui/app";
4
+
5
+ const params = new URLSearchParams(location.search);
6
+ const fixture = params.get("fixture") === "1";
7
+ const app = createApp(document.querySelector("main")!, fixture, () => void load(true));
8
+ let generation = 0;
9
+
10
+ async function load(retry = false) {
11
+ const current = ++generation;
12
+ app.render("loading", null, "Reading the current proposal…");
13
+ // A fixture always wins, even inside an iframe; importing /auto starts the SDK handshake.
14
+ if (fixture) {
15
+ const state = retry ? "ready" : params.get("state");
16
+ if (state === "loading") return;
17
+ if (state === "error") {
18
+ app.render("error", null, "Synthetic error: proposal data is unavailable. Select Retry to try again.");
19
+ return;
20
+ }
21
+ const empty = state === "empty";
22
+ app.render(empty ? "empty" : "ready", fixtureData(empty), empty
23
+ ? "Draw a building or order Overture buildings in Contextual data, then select Refresh."
24
+ : "Synthetic data. In Forma, select Refresh after editing the proposal.");
25
+ return;
26
+ }
27
+ if (window.parent === window) {
28
+ app.render("error", null, "Open this URL in Forma, or add ?fixture=1 for a synthetic preview. Then select Retry.");
29
+ return;
30
+ }
31
+ let timer: ReturnType<typeof setTimeout> | undefined;
32
+ try {
33
+ const snapshot = await Promise.race([
34
+ import("./forma").then(({ readProposal }) => readProposal()),
35
+ new Promise<never>((_, reject) => {
36
+ timer = setTimeout(() => reject(new Error("Forma did not respond within 8 seconds")), 8000);
37
+ }),
38
+ ]);
39
+ if (current !== generation) return;
40
+ app.render(snapshot.buildings.length ? "ready" : "empty", snapshot, snapshot.buildings.length
41
+ ? "Select Refresh after editing the proposal."
42
+ : "Draw a building or order Overture buildings in Contextual data, then select Refresh.");
43
+ } catch (error) {
44
+ if (current !== generation) return;
45
+ app.render("error", null, `${error instanceof Error ? error.message : String(error)}. Check the Forma project and select Retry.`);
46
+ } finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+
51
+ void load();
@@ -0,0 +1,40 @@
1
+ :root {
2
+ font-size: 10px;
3
+ --space-1: 4px;
4
+ --space-2: 8px;
5
+ --space-3: 16px;
6
+ }
7
+
8
+ * { box-sizing: border-box; }
9
+ body { margin: 0; color: var(--text-color-medium-default, #3c3c3c); background: var(--background-color-surface-100, #fff); font: var(--12-regular, 12px/16px sans-serif); }
10
+ main { padding: 0 var(--space-3) var(--space-3); width: 100%; }
11
+ header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
12
+ h1 { font: var(--12-bold); margin: 0; overflow-wrap: anywhere; }
13
+ p { margin: 0; }
14
+ [hidden] { display: none !important; }
15
+ .helper, .unit { color: var(--text-color-light); font: var(--11-regular); }
16
+ #mode { text-align: right; }
17
+ .context { font: var(--11-medium); margin-bottom: var(--space-3); overflow-wrap: anywhere; }
18
+ weave-tabs { display: block; width: 100%; }
19
+ weave-tab { width: auto; }
20
+ .tab-content { width: 100%; min-height: 120px; padding: var(--space-3) 0; vertical-align: top; }
21
+ .tab-content[aria-hidden="true"] { display: none !important; }
22
+ .metric, .field { min-height: 36px; display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
23
+ .metric-value { text-align: right; font: var(--12-medium); font-variant-numeric: tabular-nums; }
24
+ .label, label { font: var(--11-medium); }
25
+ weave-select, input { width: 60%; min-width: 0; }
26
+ input { height: 24px; border: 1px solid var(--border-color-input-box); background: var(--background-color-input-box); padding: 0 var(--space-2); color: inherit; font: var(--12-regular); }
27
+ input[aria-invalid="true"] { border-color: var(--text-color-error, #dd2222); }
28
+ input:focus-visible { outline: 2px solid var(--text-color-accent); outline-offset: 2px; }
29
+ .primary { display: block; width: 100%; margin-top: var(--space-3); }
30
+ .status, #error { display: block; min-height: 64px; margin-top: var(--space-3); overflow-wrap: anywhere; }
31
+ .status { font: var(--11-regular); color: var(--text-color-light); }
32
+ #cdn-error { margin-top: var(--space-2); }
33
+ #compact, .compact-only { display: none; }
34
+
35
+ @media (width < 300px) {
36
+ #tabs { display: none; }
37
+ #compact, .compact-only { display: block; }
38
+ #mode { display: none; }
39
+ .compact-only { margin-top: var(--space-2); }
40
+ }
@@ -0,0 +1,98 @@
1
+ import type { ProposalSnapshot } from "../forma";
2
+ import type { ViewState } from "../fixture";
3
+ import { metricRow } from "./metric-row";
4
+ import { numberInput } from "./number-input";
5
+ import { createTabs } from "./tabs";
6
+
7
+ export function createApp(root: HTMLElement, fixture: boolean, refresh: () => void) {
8
+ root.innerHTML = `
9
+ <header><h1></h1><span class="helper" id="mode"></span></header>
10
+ <p class="context" id="proposal">Current proposal</p>
11
+ <div id="tabs"></div>
12
+ <div id="compact"></div>
13
+ <p class="helper compact-only">Use Open full panel in the Forma toolbar for controls.</p>
14
+ <weave-button id="refresh" class="primary" variant="solid" density="high">Refresh</weave-button>
15
+ <div class="status" role="status" aria-live="polite"><p id="message"></p></div>
16
+ <weave-banner variant="error" id="error" hidden></weave-banner>
17
+ <p class="helper" id="cdn-error" hidden>Forma controls could not load. Reconnect to the internet and reload this view.</p>`;
18
+ root.querySelector("h1")!.textContent = document.title;
19
+ root.setAttribute("aria-label", document.title);
20
+ root.querySelector("#mode")!.textContent = fixture ? "Synthetic preview" : "Site Design";
21
+ const { tabs, summary, controls, activate } = createTabs();
22
+ root.querySelector("#tabs")!.append(tabs);
23
+ const metric = metricRow("Buildings");
24
+ const compactMetric = metricRow("Buildings");
25
+ summary.append(metric.row);
26
+ const details = document.createElement("p");
27
+ details.className = "helper";
28
+ summary.append(details);
29
+ root.querySelector("#compact")!.append(compactMetric.row);
30
+ controls.innerHTML = `
31
+ <div class="field"><span id="scope-label" class="label">Count</span>
32
+ <weave-select id="scope" value="all" density="high" aria-labelledby="scope-label">
33
+ <weave-select-option value="all">All buildings</weave-select-option>
34
+ <weave-select-option value="proposal">Proposal buildings</weave-select-option>
35
+ <weave-select-option value="existing">Existing buildings</weave-select-option>
36
+ </weave-select>
37
+ </div>`;
38
+ const { field } = numberInput("example-limit", "Example limit", 6.972);
39
+ controls.append(field);
40
+ const help = document.createElement("p");
41
+ help.id = "number-help";
42
+ help.className = "helper";
43
+ help.textContent = "Decimal input example. It does not change the building count.";
44
+ controls.append(help);
45
+
46
+ let snapshot: ProposalSnapshot | null = null;
47
+ let scope = "all";
48
+ let state: ViewState = "loading";
49
+ const paintMetric = () => {
50
+ const count = snapshot?.buildings.filter(building => scope === "all" || building.kind === scope).length ?? null;
51
+ metric.set(count);
52
+ compactMetric.set(count);
53
+ details.textContent = snapshot
54
+ ? `${snapshot.buildings.filter(b => b.kind === "proposal").length} proposal · ${snapshot.buildings.filter(b => b.kind === "existing").length} existing · ${snapshot.siteLimitPaths.length} site ${snapshot.siteLimitPaths.length === 1 ? "limit" : "limits"}. Scope: ${scope}.`
55
+ : "Read the current proposal to see its buildings.";
56
+ };
57
+ controls.querySelector("#scope")!.addEventListener("change", event => {
58
+ if (event instanceof CustomEvent && ["all", "proposal", "existing"].includes(event.detail?.value)) {
59
+ scope = event.detail.value;
60
+ paintMetric();
61
+ }
62
+ });
63
+ root.querySelector("#refresh")!.addEventListener("click", () => { if (state !== "loading") refresh(); });
64
+
65
+ const components = ["weave-tabs", "weave-tab", "weave-button", "weave-select", "weave-banner"];
66
+ const timer = setTimeout(() => { root.querySelector<HTMLElement>("#cdn-error")!.hidden = false; }, 8000);
67
+ void Promise.all(components.map(name => customElements.whenDefined(name))).then(() => {
68
+ clearTimeout(timer);
69
+ root.querySelector<HTMLElement>("#cdn-error")!.hidden = true;
70
+ activate(0);
71
+ // The native select does not forward its accessible name to the inner button.
72
+ controls.querySelector("weave-select")!.shadowRoot?.querySelector("button")?.setAttribute("aria-label", "Count");
73
+ root.querySelectorAll("weave-tab, weave-button, weave-select").forEach(element => {
74
+ const style = document.createElement("style");
75
+ style.textContent = ":focus-visible { outline: 2px solid var(--text-color-accent); outline-offset: 2px; }";
76
+ element.shadowRoot?.append(style);
77
+ });
78
+ });
79
+
80
+ return {
81
+ render(next: ViewState, data: ProposalSnapshot | null, message: string) {
82
+ state = next;
83
+ snapshot = data;
84
+ root.dataset.state = state;
85
+ root.setAttribute("aria-busy", String(state === "loading"));
86
+ root.querySelector("#proposal")!.textContent = data?.name ?? "Current proposal";
87
+ const action = root.querySelector("#refresh")!;
88
+ action.toggleAttribute("disabled", state === "loading");
89
+ action.textContent = state === "loading" ? "Loading…" : state === "error" ? "Retry" : "Refresh";
90
+ root.querySelector("#message")!.textContent = message;
91
+ const error = root.querySelector<HTMLElement>("#error")!;
92
+ error.hidden = state !== "error";
93
+ error.textContent = state === "error" ? message : "";
94
+ root.querySelector<HTMLElement>(".status")!.hidden = state === "error";
95
+ paintMetric();
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,17 @@
1
+ const format = new Intl.NumberFormat("en-US");
2
+
3
+ export function metricRow(label: string, unit = "") {
4
+ const row = document.createElement("div");
5
+ row.className = "metric";
6
+ const caption = document.createElement("span");
7
+ caption.textContent = label;
8
+ const value = document.createElement("span");
9
+ value.className = "metric-value";
10
+ const amount = document.createElement("span");
11
+ const suffix = document.createElement("span");
12
+ suffix.className = "unit";
13
+ suffix.textContent = unit ? ` ${unit}` : "";
14
+ value.append(amount, suffix);
15
+ row.append(caption, value);
16
+ return { row, set: (number: number | null) => { amount.textContent = number === null ? "—" : format.format(number); } };
17
+ }
@@ -0,0 +1,40 @@
1
+ const decimal = new Intl.NumberFormat("en-US", {
2
+ useGrouping: false,
3
+ maximumFractionDigits: 15,
4
+ });
5
+
6
+ export function parseDecimal(raw: string): number | undefined {
7
+ const text = raw.trim();
8
+ if (!text) return undefined;
9
+ // Grouping is forbidden: a single comma always means the decimal separator.
10
+ return /^[+-]?(?:\d+(?:[.,]\d*)?|[.,]\d+)$/.test(text)
11
+ ? Number(text.replace(",", ".")) : NaN;
12
+ }
13
+
14
+ export function numberInput(id: string, label: string, initial: number) {
15
+ const field = document.createElement("div");
16
+ field.className = "field";
17
+ const caption = document.createElement("label");
18
+ caption.htmlFor = id;
19
+ caption.textContent = label;
20
+ const input = document.createElement("input");
21
+ input.id = id;
22
+ input.type = "text";
23
+ input.inputMode = "decimal";
24
+ input.value = decimal.format(initial);
25
+ input.setAttribute("aria-describedby", "number-help");
26
+ const validate = () => {
27
+ const value = parseDecimal(input.value);
28
+ const valid = value !== undefined && Number.isFinite(value) && value >= 0;
29
+ input.setCustomValidity(valid ? "" : "Enter a non-negative number with . or , and no grouping separators.");
30
+ input.setAttribute("aria-invalid", String(!valid));
31
+ return valid;
32
+ };
33
+ input.addEventListener("input", validate);
34
+ input.addEventListener("change", () => {
35
+ if (validate()) input.value = decimal.format(parseDecimal(input.value)!);
36
+ else input.reportValidity();
37
+ });
38
+ field.append(caption, input);
39
+ return { field, input };
40
+ }
@@ -0,0 +1,23 @@
1
+ export function createTabs() {
2
+ const tabs = document.createElement("weave-tabs");
3
+ tabs.setAttribute("init", "0");
4
+ tabs.setAttribute("gap", "8");
5
+ tabs.setAttribute("variant", "underlined");
6
+ const panels = ["Summary", "Controls"].map((label, index) => {
7
+ const tab = document.createElement("weave-tab");
8
+ tab.setAttribute("label", label);
9
+ tab.setAttribute("variant", "underlined");
10
+ tab.setAttribute("hpadding", "8");
11
+ const panel = document.createElement("section");
12
+ panel.slot = "content";
13
+ panel.className = "tab-content";
14
+ panel.setAttribute("aria-label", label);
15
+ panel.setAttribute("aria-hidden", String(index !== 0));
16
+ tabs.append(tab);
17
+ return panel;
18
+ });
19
+ tabs.append(...panels);
20
+ // CDN tabs need ordered content slots; use the component's setter after connection.
21
+ const activate = (index: number) => { (tabs as HTMLElement & { init: number }).init = index; };
22
+ return { tabs, summary: panels[0], controls: panels[1], activate };
23
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "isolatedModules": true
11
+ },
12
+ "include": ["src", "vite.config.ts"]
13
+ }
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "vite";
2
+
3
+ export default defineConfig({
4
+ server: { port: 5173, strictPort: true },
5
+ });