diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d4db3eb --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Firebase web config — enables accounts and cloud drawings. +# +# OPTIONAL. Without it IPD Studio runs fully local: draw, validate, export and +# save .pnid files to your computer, with the sign-in screen and cloud drawings +# switched off. Nothing about drawing needs a backend. +# +# To enable accounts, create your own Firebase project (free tier is plenty): +# 1. https://console.firebase.google.com → Add project +# 2. Build → Authentication → Sign-in method → enable Email/Password (and Google) +# 3. Build → Firestore Database → Create database +# 4. Project settings → Your apps → Web app → copy the config values below +# 5. firebase deploy --only firestore:rules (uses this repo's firestore.rules) +# +# Then: cp .env.example .env.local and fill these in. +# .env.local is git-ignored — never commit real values. +# +# These are not secrets: a Firebase web config is a public project identifier +# and ships in the client bundle. Your data is protected by firestore.rules and +# the Authentication → Settings → Authorized domains list, not by hiding these. + +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_STORAGE_BUCKET= +VITE_FIREBASE_SENDER_ID= +VITE_FIREBASE_APP_ID= diff --git a/.firebaserc b/.firebaserc deleted file mode 100644 index e0aaaaf..0000000 --- a/.firebaserc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "projects": { "default": "pid-studio-praharsh" } -} diff --git a/.firebaserc.example b/.firebaserc.example new file mode 100644 index 0000000..5a98f50 --- /dev/null +++ b/.firebaserc.example @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "your-firebase-project-id" + } +} diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 499175b..1e8ec6c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,3 +3,9 @@ contact_links: - name: Try it first url: https://pid-studio-praharsh.web.app about: The live app — reproduce your issue here to rule out stale local builds. + - name: Commercial licensing + url: https://github.com/Coldbari/IPD-Studio/blob/main/COMMERCIAL-LICENSE.md + about: Using IPD Studio at a company or for paid work? A commercial licence is required — start here. + - name: Report a security vulnerability + url: https://github.com/Coldbari/IPD-Studio/security/advisories/new + about: Please report security issues privately, not as a public issue. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1472fbf..6099bf3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,6 +10,14 @@ - [ ] `npm run build` passes (type-check + build) - [ ] New logic comes with tests (see CONTRIBUTING — tests are the contract) +## Licensing + +- [ ] I agree to the [CLA](../blob/main/CONTRIBUTING.md#contributor-license-agreement-cla) + (paste the grant line in a comment — once per contributor, covers all + future PRs) +- [ ] New source files carry the SPDX header +- [ ] No code copied from GPL/AGPL-licensed sources + ## For symbol PRs only - [ ] Authored independently from geometric first principles — **not** traced or diff --git a/.gitignore b/.gitignore index ffa4a12..fc6a205 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,15 @@ playwright-report/ # Claude Code worktrees/session state .claude/ tsconfig.tsbuildinfo + +# Stray scratch files +zz*.html + +# Local secrets & per-developer backend config (.env.example is committed) +.env +.env.* +!.env.example + +# Points the firebase CLI at ONE project — a fork must supply its own +# (see .firebaserc.example) +.firebaserc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fab2b02 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,218 @@ +# Changelog + +All notable changes to IPD Studio. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.15.0] — 2026-09-01 — the engineering registry + +Second step of the engineering-platform plan +([docs/ENGINEERING-PLATFORM-PLAN.md](docs/ENGINEERING-PLATFORM-PLAN.md), §4.1). +Engineering data finally has a home of its own. + +### ⚠️ Document format: schemaVersion 4 → 5 + +Documents saved by this version **cannot be opened by v0.14.0 or earlier** +(older builds reject an unknown schema version rather than silently misreading +it). Opening an older document is unaffected — v1 through v4 all migrate in. + +### Added + +- **Engineering records** (`doc.registry`) — every tagged object gets a record + holding what it *is*, separate from where it is drawn. Records are keyed by + **tag**, not node id, so they survive deleting and redrawing a symbol, and + follow it through a rename. +- **Inspector "Engineering" tab** — the record for the selected object, with a + field catalog per kind: instruments get calibrated range and signal, valves + get trim, Cv and fail position, equipment gets duty and construction, lines + get pipe class, design conditions, insulation and tracing. A fill meter shows + how complete the record is. +- **Record status** — draft / in review / approved / issued, ready for the + review workflow. +- **Orphan records are surfaced, not silently kept** — deleting a symbol leaves + its record behind on purpose (deleting a symbol and discarding an approved + datasheet are different intentions). The advisor reports the orphan and offers + to purge it. + +### Changed + +- **The datasheet form and the Engineering tab are two views of one record**, + not two stores. The printed datasheet and the datasheet-matrix CSV read the + same values. +- **`node.datasheet` is deprecated but still read.** Values migrate into the + registry on load, and every reader falls back to the old field for one + release, so nothing is lost and a half-migrated document still shows its data. + A datasheet on an untagged symbol is parked rather than dropped. +- Removed `PlantNode.attrs` — declared since v1, never read or written. + +### Store invariants (each covered by a test) + +- A rename **moves** the record; if another symbol still wears the old tag it + **copies** instead. +- A rename onto a tag that already has a record **refuses to merge** and leaves + both intact — merging two engineering records is unrecoverable. +- Deleting a symbol **never** deletes its record. +- A pasted symbol arrives untagged and gets **no** copied record. + +## [0.14.0] — 2026-09-01 — the navigation shell + +First step of the engineering-platform plan +([docs/ENGINEERING-PLATFORM-PLAN.md](docs/ENGINEERING-PLATFORM-PLAN.md), §4.0). +No new document data: this release makes room for the five features that follow +and improves navigation on its own merits. + +### Added + +- **Workspace rail** — top-level navigation down the left edge: Draw, Data, + Checks, HMI. The toolbar was full (~18 controls already scrolling), with + nowhere to hang a second screen. `Ctrl+1..4` switches; the Checks entry + carries a badge when there are findings. +- **Workspaces are real routes** — `/app/draw`, `/app/data`, `/app/checks`, + `/app/hmi`. Linkable, back-button-able, and each a lazy chunk, so Draw does + not pay for the report tables or the HMI simulator. Bare `/app` and any older + deep link still open the drawing. +- **Command palette (`Ctrl+K`)** — jump to any tag from any workspace, or type + `>` for commands. `Ctrl+F` still opens it, so the find-a-tag habit is intact. + Replaces the search overlay. +- **Data workspace** — the instrument index and line list on screen instead of + only in a downloaded CSV, built from the *same* rows the CSV writers emit, so + the screen and the report cannot disagree. Every row locates its object back + on the sheet. +- **Checks workspace** — every finding and suggestion, full screen and grouped, + instead of a 190 px drawer. Fixes can be applied from here. +- **Inspector "Where used" tab** — for a selected object: its sheet, the rest of + its loop, its connected lines, and the HMI screens showing it. All derived; + every row is a jump. + +### Changed + +- **Cloud saves are ~50% smaller.** `serializeDoc()` pretty-printed with + `JSON.stringify(doc, null, 2)` and the cloud payload used that verbatim, so + half the 900 kB Firestore budget was indentation — measured at 328 B/item + pretty against 165 B/item compact on the 3-sheet refinery sample. The `.pnid` + file stays pretty; only the cloud copy is compact. +- **Validation runs once per edit, not three times.** The status bar, the drawer + and the validation panel each held their own `useMemo(doc)`, so every keystroke + ran the checks 3× and the suggestions 2×, each walking every sheet. They now + share one pass (`src/validate/issues.ts`), which also makes it impossible for + two panels to disagree about what is wrong. +- **Drawer: Validation + Advisor merged into one Issues tab.** They were always + one engine split by an implicit severity flag; the full report lives in Checks. +- The HMI button left the toolbar for the rail. + +## [0.13.0] — 2026-08-31 + +### ⚠️ Licence change — BREAKING for commercial users + +IPD Studio moved from **AGPL-3.0-only** to +**[PolyForm Noncommercial 1.0.0](LICENSE)**. The project is now +*source-available*, not open source. + +- **Free, no permission needed** — personal use, study, research, hobby + projects, students, educational institutions, charities, public research + bodies, public health and safety organizations, environmental organizations, + and government institutions. +- **Now requires a paid licence** — any use by or on behalf of a for-profit + company, including internal use, paid client work, paid operator training, + and embedding or hosting IPD Studio in anything you sell. + See [COMMERCIAL-LICENSE.md](COMMERCIAL-LICENSE.md). +- **Not retroactive.** Versions **up to and including v0.12.1 remain available + under AGPL-3.0-only** to everyone who received them. That grant is perpetual + and is not revoked. If you depend on AGPL terms, v0.12.1 is unaffected. +- Your drawings are unaffected either way — the licence covers the software, + not the documents you produce with it. + +### Added + +- `NOTICE` — copyright, licence summary, licence history, and third-party + component attributions +- `SECURITY.md` — private vulnerability reporting, threat model for a + local-first client-side app, and the HMI "not for real operations" notice +- `CHANGELOG.md` — this file +- SPDX headers on all 137 source files +- `@license` build banner preserved through minification, so the terms travel + with any copy of the bundle +- Contributor Licence Agreement in [CONTRIBUTING.md](CONTRIBUTING.md), needed + for code contributions so commercial licences can be granted + +### Changed + +- README, project site, in-app strings, and the PWA manifest no longer + describe the project as open source or free for commercial use +- [docs/LICENSE-ENFORCEMENT.md](docs/LICENSE-ENFORCEMENT.md) rewritten for the + new licence, including the PolyForm 32-day cure clock and a warning to + establish a copy's version before alleging any violation +- [docs/DWG-IMPORT-SPIKE.md](docs/DWG-IMPORT-SPIKE.md) — the GPLv3 LibreDWG + option is now closed; GPL code cannot be combined with a noncommercial + licence. ODA/Teigha becomes the only viable path. + +### Fixed + +- Stale clone URL in CONTRIBUTING (`pid-studio` → `IPD-Studio`) +- CONTRIBUTING no longer claims the project is "CI-less"; CI has existed since + v0.9.19 + +## [0.12.0] — 2026-08-27 + +- The canvas is a real viewport: Fit fits your screen, plus a workspace design + pass +- Moving a group of symbols now carries their lines with them + +## [0.11.0] — 2026-08-26 + +- Researched component prices; budget moves to the toolbar; real currency + conversion +- **0.11.1** — enter budgets in lakh/crore (or K/M/B) instead of counting zeros +- **0.11.2** — the component panel shows its budgetary price, basis and range, + and says when you override it + +## [0.10.0] — 2026-08-26 + +- Budget & cost estimator: budgetary price table, live total vs budget, + per-project and per-component overrides, cost CSV export + +## [0.9.0] – [0.9.20] — 2026-08-21 → 2026-08-26 + +Rebranded to **IPD Studio** (0.9.15) and built out HMI Studio: + +- HMI tag picker — bind by picking, never typing (0.9.0) +- DCS-style operation: faceplate v2, bumpless transfer, command journal (0.9.2) +- Trends with a real time axis: multi-pen, mm:ss, hover cursor, sparklines (0.9.3) +- ISA-18.2 alarms: deadband, 3 priorities, shelve/OOS/SBD (0.9.4) +- Screens & chrome: home screen, drag-reorder, Modal, run header (0.9.5) +- Import v2: multi-sheet dialog + generated L1 plant overview (0.9.6) +- Simulation depth: fan-out flow, equipment dynamics, process events (0.9.7) +- Import fidelity: routed pipes, vessel shapes, orientation (0.9.8–0.9.9) +- Equipment pack: compressor/blower/agitator/conveyor/heater with motor + dynamics (0.9.10) +- Full ISA instrumentation palette with shortcut and full-name search (0.9.11) +- Frequent-industry symbol pack — 11 symbols across 5 sections (0.9.12) +- Fluid services: define media, assign to a line, colour flows through the + whole run; HMI pipes inherit (0.9.13–0.9.14) +- Sponsorship, welcome overlay, update toast, Dependabot + CodeQL (0.9.16–0.9.20) + +## [0.8.0] – [0.8.8] — 2026-08-21 + +- User-added connection pins and the ISA valve positioner (0.8.3) +- Control valve geometry reworked to 64×48 with clear stubs and no overlaps + (0.8.4–0.8.8) +- Line lifecycle: no resurrection, gesture-granular bends, segment dragging + (0.8.2) +- Imported real plants now come up calm rather than alarming (0.8.1) + +## [0.6.0] – [0.7.x] — 2026-08-20 + +- HMI Studio first release with memoized widget rendering (0.6.0) +- Polish round from user feedback (0.6.1) + +## [0.1.0] – [0.5.x] — 2026-08-20 + +Initial development: the P&ID editor core — ISA-5.1 symbol library, tag +parsing and validation, orthogonal routing with ports and connection rules, +multi-sheet projects, DEXPI/DXF import and export, generated loop diagrams +and datasheets, and the `.pnid.json` document format. + +[0.13.0]: https://github.com/Coldbari/IPD-Studio/releases/tag/v0.13.0 +[0.12.0]: https://github.com/Coldbari/IPD-Studio/releases/tag/v0.12.0 +[0.11.0]: https://github.com/Coldbari/IPD-Studio/releases/tag/v0.11.0 +[0.10.0]: https://github.com/Coldbari/IPD-Studio/releases/tag/v0.10.0 diff --git a/COMMERCIAL-LICENSE.md b/COMMERCIAL-LICENSE.md index 17d7a3f..49896a9 100644 --- a/COMMERCIAL-LICENSE.md +++ b/COMMERCIAL-LICENSE.md @@ -1,51 +1,86 @@ -# Commercial licensing +# Licensing & commercial use -IPD Studio is dual-licensed. +IPD Studio is **source-available, not open source**. The code is public so you +can read it, learn from it, and verify what it does — but the license permits +**noncommercial use only**. -## 1. AGPL-3.0 (default, free) +## 1. Noncommercial license (free) -The code in this repository is licensed under the **GNU AGPL-3.0-only** -([LICENSE](LICENSE)). In plain words: +The code in this repository is licensed under the +**[PolyForm Noncommercial License 1.0.0](LICENSE)**. -- ✅ **Using IPD Studio is free for everyone — including at work.** Draw - P&IDs for commercial projects, use the HMI for paid operator training, - keep every drawing you make. Your documents are yours; the license covers - the software, not its output. -- ✅ Studying, modifying, and self-hosting it is free — as long as you - publish your modified source code under the AGPL too. -- ⚠️ **Redistributing or hosting IPD Studio (modified or not) as part of a - product or service requires you to release your complete corresponding - source code under AGPL-3.0.** This includes SaaS: if users interact with - your modified version over a network, they must be offered the source - (AGPL §13). +Free, with no permission needed, for: + +- ✅ **Personal use** — study, research, experiment, hobby projects, learning + P&ID and HMI conventions on your own time +- ✅ **Students** — coursework, projects, theses +- ✅ **Educational institutions** — teaching, labs, university research +- ✅ **Charitable organizations, public research bodies, public safety and + health organizations, environmental protection organizations, and + government institutions** — permitted regardless of how they are funded +- ✅ **Reading and modifying the source** for any of the above +- ✅ **Your drawings are always yours.** The license covers the software, not + the documents you produce with it. ## 2. Commercial license (paid) -If you want to build on IPD Studio **without** the AGPL's source-publication -obligations — for example: +**Any use in furtherance of a commercial purpose requires a paid license.** +There is no free commercial tier. This includes: -- embedding it in a closed-source or proprietary product, -- offering a hosted/white-labeled version without publishing your changes, -- distributing it under different terms to your customers, +- ❌ Use by, or on behalf of, a for-profit company — including internal use +- ❌ Drawing P&IDs for paid client or consulting work +- ❌ Delivering paid operator training with HMI Studio +- ❌ Embedding IPD Studio in a product or service you sell +- ❌ Hosting it for customers, white-labeled or otherwise +- ❌ Redistributing it commercially, modified or not -then you need a commercial license. That's a normal, supported path — it's -how this free project sustains itself. +That is a normal, supported path — it is how this project sustains itself, and +the terms are friendly. **Contact:** praharshchamp610@gmail.com with a short description of your use -case. Pricing depends on scope; responses are quick. +case and rough seat count. Pricing depends on scope; responses are quick. +Startups, small consultancies, and single-seat users get proportionate pricing — +ask. ## Which one do I need? | You want to… | License needed | |---|---| -| Draw P&IDs / run HMI training at your company | **None — just use it** | -| Self-host internally, unmodified | AGPL (free) | -| Self-host a modified version, source published | AGPL (free) | -| Ship it inside a closed-source product | **Commercial** | -| Offer it as a service without publishing your source | **Commercial** | +| Learn P&ID conventions on your own laptop | **None — just use it** | +| Use it for a university course or thesis | **None — just use it** | +| Use it at an NGO, school, hospital, or government body | **None — just use it** | +| Modify it for any of the above | **None — just use it** | +| Use it at your for-profit employer, even internally | **Commercial** | +| Draw P&IDs for a paying client | **Commercial** | +| Run paid operator training | **Commercial** | +| Ship it inside a product you sell | **Commercial** | +| Offer it as a hosted service | **Commercial** | + +Not sure which side of the line you're on? Email and ask — an honest question +gets an honest, quick answer, and it is always cheaper than guessing wrong. + +## Contributions + +Because this project is dual-licensed (noncommercial for everyone, paid +commercial licenses for companies), contributors are asked to agree to the +Contributor License Agreement in [CONTRIBUTING.md](CONTRIBUTING.md#contributor-license-agreement-cla) +before a pull request can be merged. + +## License history + +Versions up to and including **v0.12.1** were released under the +**GNU AGPL-3.0-only**. That grant is perpetual and is not revoked: anyone who +received those versions keeps their AGPL rights to them, and the two existing +GitHub forks remain AGPL. + +**v0.13.0 and later** are licensed under PolyForm Noncommercial 1.0.0. + +Practically: if you rely on AGPL terms, you may keep using v0.12.1 under the +AGPL. Everything released from v0.13.0 onward is noncommercial-only unless you +hold a commercial license. ## Copyright Copyright © 2026 Praharsh Nagpure. All rights reserved beyond the grants of -the licenses above. Unauthorized proprietary redistribution is a copyright -violation and is enforced (see `docs/LICENSE-ENFORCEMENT.md`). +the licenses above. Unauthorized commercial use is a copyright violation and +is enforced — see [docs/LICENSE-ENFORCEMENT.md](docs/LICENSE-ENFORCEMENT.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 066e84f..de90e7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to IPD Studio -Thanks for helping build the free, browser-native intelligent P&ID tool. +Thanks for helping build the browser-native intelligent P&ID tool. All contributions are welcome: bug fixes, new symbols, HMI widgets, docs, translations of engineering conventions you know from your industry — even a well-written bug report with a `.pnid` file attached moves the project. @@ -10,8 +10,8 @@ well-written bug report with a `.pnid` file attached moves the project. 1. **Fork** the repo on GitHub (button top-right), then clone your fork: ```bash - git clone https://github.com//pid-studio.git - cd pid-studio + git clone https://github.com//IPD-Studio.git + cd IPD-Studio npm install npm run dev # editor at http://localhost:5173 ``` @@ -49,14 +49,39 @@ well-written bug report with a `.pnid` file attached moves the project. git push -u origin feat/foot-valve-symbol ``` - The PR template asks how you verified it — fill it honestly. CI-less for - now, so your local test run *is* the gate. + The PR template asks how you verified it — fill it honestly. CI runs the + same gates on your PR, but a green local run first saves a round trip. 7. **Review.** Expect concrete feedback; push follow-up commits to the same branch (no force-push needed). Once approved it's squash-merged, and your change ships to [the live app](https://pid-studio-praharsh.web.app) with the next release. +## Contributor License Agreement (CLA) + +IPD Studio is **source-available under [PolyForm Noncommercial +1.0.0](LICENSE)**, and commercial licenses are sold to fund the project. For +that to work, one party has to be able to grant those commercial licenses. + +So before a code PR can be merged, please paste this line into the PR (once — +it covers all your future contributions): + +> I grant Praharsh Nagpure a perpetual, worldwide, irrevocable, royalty-free +> license to use, modify, sublicense, and relicense my contribution, including +> under commercial terms. I confirm the work is mine to give and that my +> employer, if any, has no claim to it. + +You keep the copyright to your contribution — this is a license grant, not an +assignment. It exists purely so commercial licenses can be sold without having +to track down every contributor. + +**Nothing is required for** issues, bug reports, symbol requests, convention +corrections, or discussion. Those are the most valuable contributions anyway, +and they carry no paperwork. + +If your employer's policy makes the CLA impossible, say so in the issue — +a convention correction that I implement myself is still a real contribution. + ## What we especially need - **Symbols** you miss from real drawings (follow "Adding a symbol" below — @@ -113,3 +138,20 @@ generate symbols. PRs that can't establish independent authorship are closed. Every logic change lands with tests (TDD preferred). UI-only changes need at minimum a passing build plus the e2e happy path. + +## Licensing of your contribution + +Merged contributions ship under [PolyForm Noncommercial 1.0.0](LICENSE) like +the rest of the codebase, and may also be included in commercially licensed +builds under the CLA above. Please add the standard SPDX header to any new +source file: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). +``` + +Never paste in code from a source whose license you haven't checked, and never +paste in code under a copyleft license (GPL/AGPL) — it cannot be commercially +relicensed and would have to be reverted. diff --git a/LICENSE b/LICENSE index be3f7b2..6da9852 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,133 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +# PolyForm Noncommercial License 1.0.0 + + + +Required Notice: Copyright © 2026 Praharsh Nagpure (https://github.com/Coldbari/IPD-Studio) + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright © 2026 Praharsh Nagpure (https://github.com/Coldbari/IPD-Studio) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, +or government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to anyone for any damages related to the software or these +terms, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..37ff2be --- /dev/null +++ b/NOTICE @@ -0,0 +1,57 @@ +IPD Studio +Copyright © 2026 Praharsh Nagpure. All rights reserved. + +Required Notice: Copyright © 2026 Praharsh Nagpure (https://github.com/Coldbari/IPD-Studio) + +This software is licensed under the PolyForm Noncommercial License 1.0.0. +See LICENSE for the full terms, or https://polyformproject.org/licenses/noncommercial/1.0.0 + + COMMERCIAL USE IS NOT PERMITTED under this license. Any use in + furtherance of a commercial purpose — including use by or for a + for-profit company, use in paid consulting or engineering work, use + in a product or service you sell, and paid training delivery — + requires a separate paid commercial license. + + Contact praharshchamp610@gmail.com. See COMMERCIAL-LICENSE.md. + +Permitted at no cost: personal study, research, experimentation, hobby +projects, and use by charitable organizations, educational institutions, +public research organizations, public safety/health organizations, +environmental protection organizations, and government institutions. + +-------------------------------------------------------------------------- +LICENSE HISTORY + +Versions of IPD Studio released up to and including v0.12.1 were published +under the GNU AGPL-3.0-only license. Those releases remain available under +AGPL-3.0-only to anyone who received them; that grant is perpetual and is +not revoked by this change. + +Version 0.13.0 and later are licensed under PolyForm Noncommercial 1.0.0 +as stated above. + +-------------------------------------------------------------------------- +THIRD-PARTY COMPONENTS + +This product bundles third-party open-source software. Each component +remains under its own license; those licenses are unaffected by the terms +above and their texts ship in node_modules//LICENSE. + + @joint/core Mozilla Public License 2.0 + react, react-dom MIT + zustand, zundo MIT + idb-keyval Apache-2.0 + dxf-parser MIT + fast-xml-parser MIT + ulid MIT + +-------------------------------------------------------------------------- +SYMBOLS AND STANDARDS + +All symbols are original works, authored independently from public-domain +geometric drawing conventions. No artwork was traced, copied, or vectorized +from the ISA-5.1 standard document, vendor libraries, or other software. + +This project is not affiliated with, sponsored by, or endorsed by the +International Society of Automation. "ISA" is referenced solely to describe +the drawing conventions the symbols follow. diff --git a/README.md b/README.md index bab942f..9b795d6 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,20 @@ [![CI](https://github.com/Coldbari/IPD-Studio/actions/workflows/ci.yml/badge.svg)](https://github.com/Coldbari/IPD-Studio/actions/workflows/ci.yml) [![Buy me a coffee](https://img.shields.io/badge/%E2%98%95%20Buy%20me%20a%20coffee-keep%20the%20pumps%20running-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Coldbari) [![Live demo](https://img.shields.io/badge/Live%20demo-%E2%96%B6-2b6cb0)](https://pid-studio-praharsh.web.app) -[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue)](LICENSE) -[![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md) +[![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial%201.0.0-orange)](LICENSE) +[![Commercial use: license required](https://img.shields.io/badge/Commercial%20use-license%20required-red)](COMMERCIAL-LICENSE.md) -**Open-source, browser-based intelligent P&ID editor.** Drag ISA-5.1-style +**Source-available, browser-based intelligent P&ID editor.** Drag ISA-5.1-style symbols onto a sheet, connect process and signal lines with proper orthogonal routing, tag instruments with validated ISA tags — and generate real engineering deliverables. -Local-first: no backend, no account, no upload. Drawings live on your machine. +Sign in once and your drawings follow you: every P&ID, HMI screen and cost +estimate is saved to your account and opens on whatever machine you use next. + +> **Licensing in one line:** free for personal, academic, nonprofit, and +> government use — **commercial use requires a paid license.** +> See [COMMERCIAL-LICENSE.md](COMMERCIAL-LICENSE.md). **▶ Try it now: [pid-studio-praharsh.web.app](https://pid-studio-praharsh.web.app)** — pick *Templates → Sample plant* for a demo. **🌐 Project site: [coldbari.github.io/IPD-Studio](https://coldbari.github.io/IPD-Studio/)** @@ -37,7 +42,7 @@ Local-first: no backend, no account, no upload. Drawings live on your machine. Every browser diagramming tool treats a P&ID as clipart. Every intelligent P&ID tool is a $2,600+/year desktop install. IPD Studio is the missing thing: -**data behind the drawing, in the browser, free (AGPL).** +**data behind the drawing, in the browser, free for noncommercial use.** - Tags are parsed and validated against ISA-5.1 letter tables — type `FIC` and the editor knows it's a *Flow Indicating Controller* @@ -46,7 +51,7 @@ P&ID tool is a $2,600+/year desktop install. IPD Studio is the missing thing: - The instrument index and line list are generated from the model, not typed - The document is versioned open JSON, designed to map onto DEXPI -## Features (v0.9) +## Features - **HMI Studio** — build operator screens from your P&ID (or import them in one click) and run them as a live training simulation: DCS-style faceplates, @@ -122,6 +127,31 @@ npm test # 600+ unit tests npx playwright test # e2e suite (starts its own dev server) ``` +Building and running it yourself is free for noncommercial use. Running it for +or inside a business needs a [commercial license](COMMERCIAL-LICENSE.md). + +## Running your own instance + +IPD Studio runs **fully local out of the box**. Clone, `npm install`, `npm run +dev` — draw, validate, generate deliverables, and save `.pnid` files to your +computer. No backend, no account, no configuration. + +Accounts and cloud drawings are the only things that need a server, and they are +switched off unless you point the app at a Firebase project of your own: + +```bash +cp .env.example .env.local # then fill in your own Firebase web config +``` + +`.env.example` walks through creating the project. This repo ships the +`firestore.rules` that protect the data, so `firebase deploy --only +firestore:rules` reproduces the same security model. + +**A fork never talks to the upstream project.** There is no built-in Firebase +config to inherit — without `.env.local` the sign-in screen and cloud drawings +simply don't appear. That is deliberate: a hardcoded fallback would sign a +fork's users into someone else's database. + ## File format Drawings save as `.pnid.json` — versioned, human-readable JSON @@ -146,16 +176,25 @@ architecture in one paragraph, and the how-to for adding a symbol. If you're a practicing I&C or process engineer, even a "this convention is wrong" issue is a valuable contribution. +Because the project sells commercial licenses to fund itself, code +contributions need a one-line +[CLA](CONTRIBUTING.md#contributor-license-agreement-cla). Issues, bug reports, +and symbol requests need nothing. + ## ☕ Buy me a coffee -IPD Studio is free, and stays free. But behind every calm-starting plant and -every routed pipe there's a developer running on coffee. +IPD Studio is free for students, hobbyists, researchers, and nonprofits — and +stays that way. But behind every calm-starting plant and every routed pipe +there's a developer running on coffee. **[☕ Buy me a coffee → keep the pumps running](https://github.com/sponsors/Coldbari)** One coffee a month keeps the symbols coming; a bigger one moves your feature -requests to the front of the queue. If this tool just saved you a $2,600/year -license — a coffee is a pretty good deal for both of us. 😄 +requests to the front of the queue. + +*Using it at a company? That's a [commercial +license](COMMERCIAL-LICENSE.md), not a coffee — and it's what keeps the tool +free for everyone above.* ## Symbols & standards @@ -166,17 +205,33 @@ the drawing conventions the symbols follow. ## License & commercial use -**AGPL-3.0-only** — free forever for everyone, including professional use: -draw commercial P&IDs, run paid training, keep every drawing. The license -covers the software, not your documents. +**[PolyForm Noncommercial 1.0.0](LICENSE)** — source-available, not open +source. The code is public so you can read it, learn from it, and verify what +it does. Commercial use is not granted. + +**Free, no permission needed:** + +- Personal use, study, research, hobby projects +- Students and coursework +- Educational institutions, universities, labs +- Charities, public research bodies, public health and safety organizations, + environmental organizations, and government institutions + +**Requires a paid commercial license:** + +- Use by or on behalf of a for-profit company, *including internal use* +- P&IDs drawn for paid client or consulting work +- Paid operator training delivered with HMI Studio +- Embedding, hosting, or reselling IPD Studio in any form + +Your drawings are always yours — the license covers the software, not the +documents you produce with it. -The protection: anyone who **redistributes or hosts a modified IPD Studio** -— including as a web service — must publish their complete source under the -AGPL as well. Proprietary copies are not permitted. +Commercial licensing is friendly and priced to scale: +[COMMERCIAL-LICENSE.md](COMMERCIAL-LICENSE.md) or praharshchamp610@gmail.com. -Want to embed IPD Studio in a closed-source product or host a modified -version without publishing your changes? A **paid commercial license** is -available — see [COMMERCIAL-LICENSE.md](COMMERCIAL-LICENSE.md) or write to -praharshchamp610@gmail.com. +**License history:** versions up to **v0.12.1** were released under +AGPL-3.0-only, and that grant is perpetual for anyone who received them. +**v0.13.0 and later** are PolyForm Noncommercial. -Copyright © 2026 Praharsh Nagpure. See [LICENSE](LICENSE). +Copyright © 2026 Praharsh Nagpure. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7ee934a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,74 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---|---| +| 0.13.x (current) | ✅ Security fixes | +| ≤ 0.12.x | ❌ Not supported — please upgrade | + +## Reporting a vulnerability + +**Please do not open a public issue for security problems.** + +Report privately via either: + +- **GitHub Security Advisories** — [report a vulnerability](https://github.com/Coldbari/IPD-Studio/security/advisories/new) + (preferred; keeps the discussion private until a fix ships) +- **Email** — praharshchamp610@gmail.com with `SECURITY` in the subject + +Please include: what you found, how to reproduce it, the affected version, and +the impact as you see it. A proof-of-concept `.pnid.json` or SVG file is ideal. + +**Response:** initial acknowledgement within 72 hours, an assessment within 7 +days, and a fix or a documented mitigation for confirmed issues before public +disclosure. Please allow 90 days before disclosing publicly. Reporters are +credited in the advisory and the changelog unless they prefer otherwise. + +## Threat model + +IPD Studio is a **local-first, client-side browser application**. There is no +backend, no account system, and no server that stores user data — drawings live +in the browser's IndexedDB and in files on the user's own machine. That removes +most of the usual server-side attack surface and concentrates risk in what the +app *parses*. + +**In scope — the app parses untrusted files, and that's where the risk is:** + +- **Imported SVG symbols** — sanitized by `src/import/svgSymbol.ts`, which + strips scripts, event handlers, and external references. Any bypass of that + sanitizer (XSS via a crafted symbol) is a genuine vulnerability and the + highest-value thing to look for. +- **`.pnid.json` project files** — prototype pollution, or a crafted document + that achieves script execution when rendered +- **DEXPI/Proteus XML import** — XXE, entity expansion, parser abuse +- **DXF underlay import** — parser crashes, memory exhaustion, injection into + rendered output +- **Service worker / PWA caching** — cache poisoning, stale-content attacks +- Dependency vulnerabilities reachable from the shipped bundle + +**Out of scope:** + +- Anything requiring the attacker to already control the user's machine or + browser profile +- Self-XSS a user must deliberately perform on themselves +- Missing hardening headers on the demo deployment with no demonstrated impact +- Denial of service via absurdly large files the user chose to open +- Reports from automated scanners with no working proof of concept +- **Licence violations** — real, but not security. Those go to + [docs/LICENSE-ENFORCEMENT.md](docs/LICENSE-ENFORCEMENT.md). + +## Safety notice + +HMI Studio is a **training and demonstration simulator**. It is not certified +for, and must never be connected to, real plant control, safety instrumented +systems, or live process equipment. Drawings and calculations it produces are +engineering aids that require review by a qualified engineer — they are not +approved-for-construction deliverables. + +## Researching under the licence + +The licence is PolyForm Noncommercial 1.0.0, which explicitly permits use for +**research, experiment, and testing**. Security research on IPD Studio is +welcome and needs no separate permission. Please test against your own local +build rather than the shared demo deployment. diff --git a/docs/DWG-IMPORT-SPIKE.md b/docs/DWG-IMPORT-SPIKE.md index b281817..4fd68d0 100644 --- a/docs/DWG-IMPORT-SPIKE.md +++ b/docs/DWG-IMPORT-SPIKE.md @@ -6,8 +6,8 @@ | Option | Verdict | |---|---| -| **LibreDWG (wasm)** | Functional DWG→DXF conversion exists (`libredwg-web` builds). Cost: ~6–9 MB of wasm shipped to every visitor, GPLv3 (license-compatible with our AGPL, but heavy), and DWG version coverage is uneven for 2018+ files. | -| **ODA / Teigha SDK** | The industry-grade answer, used by almost every commercial tool. Proprietary, per-product licensing with fees — incompatible with an AGPL no-cost project. | +| **LibreDWG (wasm)** | Functional DWG→DXF conversion exists (`libredwg-web` builds). Cost: ~6–9 MB of wasm shipped to every visitor, GPLv3 — **no longer usable**: since v0.13.0 the project is PolyForm Noncommercial, and GPL code cannot be combined with a noncommercial licence or sublicensed commercially. This option is closed unless the project relicenses, and DWG version coverage is uneven for 2018+ files. | +| **ODA / Teigha SDK** | The industry-grade answer, used by almost every commercial tool. Proprietary, per-product licensing with fees. Now the only viable path: commercial licence revenue could fund it, and its terms are compatible with a source-available commercial offering. | | **Cloud conversion API** | Works (e.g. CloudConvert), but breaks the local-first promise: drawings would leave the user's machine. Rejected on principle. | ## Decision diff --git a/docs/ENGINEERING-PLATFORM-PLAN.md b/docs/ENGINEERING-PLATFORM-PLAN.md new file mode 100644 index 0000000..143a4d9 --- /dev/null +++ b/docs/ENGINEERING-PLATFORM-PLAN.md @@ -0,0 +1,1120 @@ +# IPD Studio — Engineering Platform Plan + +**From "a P&ID editor with data behind it" to "an engineering data platform whose +front door is a P&ID."** + +Status: proposal · Written against `v0.13.0` (schemaVersion 4, 19,357 LOC) +Scope: Phases 1–3, with Phase 1 specified to implementation detail. + +--- + +## 0. What the audit found + +Every recommendation below is anchored to something in the current code. These +are the findings that shaped the plan. + +| # | Finding | Evidence | +|---|---------|----------| +| 1 | **Engineering data has no home.** It is scattered across `node.datasheet` (instruments only, freeform keys), `node.config` (render config that is *actually* engineering data — `cv.*` `fail` = FC/FO/FL), `edge.lineNumber` (4 strings), `node.cost`. Nothing exists for equipment specs, line specs, I/O, revision, status, or ownership. | `src/model/types.ts:45-95` | +| 2 | **`PlantNode.attrs` is declared and never used.** Zero reads, zero writes in the whole codebase. | `src/model/types.ts:70`; `grep -rn "\.attrs" src/` → nothing outside HMI | +| 3 | **Everything is derived, nothing is stored.** Instrument index, line list, loops, and costs are all computed at read time from `nodes`/`edges`. This is the project's great strength — deliverables *cannot* drift — and must be preserved, not replaced. | `src/export/csv.ts`, `src/store/selectors.ts:15`, `src/model/costs.ts:331` | +| 4 | **Records are keyed to node ids, so redrawing destroys data.** Delete an instrument bubble and redraw it and its whole ISA-20 datasheet is gone. `deleteIds` removes nodes with no record-preservation path. | `src/store/store.ts` `deleteIds`; `PlantNode.datasheet` | +| 5 | **Validation is a fixed list with a binary severity.** 7 hardcoded checks; `Finding.severity?: 'suggestion'` where *absence* means error. No rule config, no fixes (except one), no ignore. | `src/validate/checks.ts:13`, `src/model/types.ts:174-182` | +| 6 | **The advisor already does real engineering reasoning** — relief devices on vessels, controllers without final elements, I/P converters — but it is advisory-only and offers exactly one applicable fix. The engine for a QA product is half-built already. | `src/validate/suggest.ts`, `src/assist/fixes.ts` (`FixSpec` is a union of one member) | +| 7 | **Validation runs 3× per keystroke.** `useFindings()` is called independently by `StatusBar`, `Drawer`, and `ValidationPanel` — three separate `useMemo(doc)`. `useSuggestions()` runs 2×. Each walks every sheet and rebuilds its own `Map`s. | `src/panels/StatusBar.tsx:17`, `Drawer.tsx:12-13`, `ValidationPanel.tsx:38` | +| 8 | **The toolbar is already full.** It carries ~18 controls and has `overflow-x: auto` with a source comment conceding it scrolls on narrow windows. There is no room to hang five new modules off it. | `src/panels/Toolbar.tsx`, `src/app.css` `.toolbar` | +| 9 | **Cloud storage is hard-capped at 900,000 bytes** by a deployed Firestore rule, enforced server-side, on the whole drawing as one JSON string. | `firestore.rules`; `src/cloud/sync.ts:25` | +| 10 | **There is no entitlement mechanism anywhere in the app.** No licence key, no plan check, no feature gate. The v0.13.0 PolyForm move made commercial use *paid*, but nothing in the product distinguishes tiers. | `grep -rni "entitle\|tier\|subscription" src/` → nothing | + +### The storage math that constrains everything + +Measured against `examples/sample-refinery-unit.pnid.json` (3 sheets, 32 nodes, +30 edges): + +``` +pretty-printed 20,330 B = 328 B/item ← what ships to the cloud today +compact 10,201 B = 165 B/item ← 49.8% smaller, same data +``` + +`serializeDoc()` pretty-prints with `JSON.stringify(doc, null, 2)`, and +`buildPayload()` sends *that* to Firestore. **Half the cloud quota is currently +spent on indentation.** + +Projected, compact, with a ~350 B engineering record per item: + +| Items | Drawing | + records | Headroom to 900 kB | +|-------|---------|-----------|--------------------| +| 100 | 16 kB | 50 kB | 829 kB | +| 400 | 64 kB | 201 kB | 678 kB | +| 1000 | 161 kB | 502 kB | 376 kB | + +**Two conclusions that decide the architecture:** + +1. **The engineering registry fits.** Even a 1000-item project with full records + leaves 376 kB of headroom. Adding the database does not break the cloud. +2. **Snapshot-based revisions do not fit.** At 400 items you would fit two + snapshots; at 1000, zero. Revisions **must** be diffs, and above ~400 items + they must live outside the drawing blob. This is a day-one design constraint, + not a Phase-3 optimisation. + +--- + +## 1. The product decision + +Do not build a better drawing editor. Build the thing the drawing is a view of. + +> **IPD Studio turns a P&ID into a connected engineering data model, checks that +> model against your company's standards, and keeps every downstream deliverable +> consistent with it.** + +The economic claim is one sentence: *the drawing and the paperwork can no longer +disagree.* That is what an engineering department pays for, and it is a claim +this codebase is unusually well positioned to make, because finding #3 means the +generated deliverables already cannot drift. The gap is that only a fraction of +real engineering data can be entered at all. + +**The one architectural idea:** every engineering object gets a durable identity +(its tag), a record attached to that identity, and a derived view of everywhere +it appears. The drawing becomes one view of the record, not its container. + +--- + +## 2. Data architecture + +### 2.1 The engineering registry + +```ts +// src/model/registry.ts (new) + +export type EntityKind = 'instrument' | 'equipment' | 'line' | 'loop' + +export interface EngineeringRecord { + /** Canonical identity: 'FT-101', 'P-101', '6"-CW-101-001', 'LOOP-F-101'. */ + key: string + kind: EntityKind + /** Values keyed by field id from the active standard's catalog. */ + fields: Record + status?: 'draft' | 'in-review' | 'approved' | 'issued' + owner?: string + /** Revision in which this record last changed. */ + rev?: string + updated?: string +} + +// src/model/types.ts (ProjectDoc, schemaVersion 5) +registry?: Record +``` + +### 2.2 Why tag-keyed, not node-keyed + +This is the load-bearing decision. Four reasons: + +1. **A tag is the engineering identity; a node is a placement of it.** The same + tag legitimately appears more than once — an off-page continuation, a valve + shown on two sheets, a header on a utility drawing. +2. **Delete-and-redraw is normal drafting.** Node-id keying silently destroys an + approved datasheet (finding #4). Tag keying survives it. +3. **Every deliverable already joins on tag.** `instrumentIndexCsv`, + `deriveLoops`, `printLoopDiagram`, `importFromPid`'s widget binding — all of + them key on `formatTag(node.tag, '-')`. The registry joins the same way, so + nothing new has to be threaded through. +4. **It makes a duplicate tag a *data* error, not a cosmetic one** — which is the + correct severity and something the current binary model cannot express. + +**Trade-off, stated plainly:** untagged objects get no record. That is a +deliberate forcing function — "tag it before you can spec it" is how engineering +databases work — but it means equipment, which today often carries only a +`label`, must be tagged. Mitigation: the QA engine ships a fix that assigns the +next free equipment tag from a label, and the standard defines the equipment tag +format. + +### 2.3 Store invariants (must be enforced, not assumed) + +These live in `src/store/store.ts` and each gets a unit test: + +| Action | Rule | +|--------|------| +| `setTag(id, next)` | Old tag had a record, new tag has none → **move** the record (a rename carries its data). New tag already has a record → do **not** merge; raise a `record-collision` finding. The old tag is still worn by another node → **copy**, do not move. | +| `deleteIds(ids)` | **Never** delete the record. An orphan is a QA finding (`orphan-record`) with an explicit purge action. Deleting a symbol must not silently bin approved engineering data. | +| `setEdge(id, {lineNumber})` | Same move/copy/collide rules, keyed on the formatted line number. | +| `pasteNodes` / `addBatch` | Pasted tags are re-numbered (existing behaviour); records are **not** copied — a pasted symbol is a new object needing its own spec. | + +All of this sits inside the existing `patchSheet`/`set` calls, so `zundo` +`temporal` gives registry edits undo/redo for free. + +### 2.4 Migration to schemaVersion 5 + +`src/model/migrate.ts` gains a `migrateV4`: + +- `node.datasheet` → `registry[tag].fields` (keys already namespaced: + `general.service`, `signal.range` …, so they transfer verbatim). +- `node.config.fail` on `cv.*` → `registry[tag].fields['element.failPosition']`, + **mirrored** — the symbol keeps rendering from `config`, and a store action + keeps the two in step. Do not fork the source of truth for something drawn. +- `settings.numberStart`, `settings.tagSeparator`, `doc.fluids` → `doc.standard` + (§4.3), with the old fields retained and read as fallback for one release. +- Untagged nodes with a `datasheet` → tag auto-assigned, or the datasheet is + parked under `registry['__unassigned:']` and surfaced as a finding. + +**Read-fallback rule:** for one release, every reader tries +`registry[tag]?.fields[k] ?? node.datasheet?.[k]`. A v5 file opened by a v4 build +loses the registry but keeps the drawing — acceptable and non-destructive, +matching how `loadDoc` already tolerates v2/v3. + +### 2.5 Cloud storage changes + +1. `serializeDoc(doc, { pretty = true })`. The `.pnid` file stays pretty — + human-readable JSON is a documented feature. `buildPayload()` passes + `{ pretty: false }`. **One-line change, halves the cloud payload.** Do this in + the first release, before the registry needs the room. +2. Revisions are **diffs**, not snapshots (§4.5). +3. Above a size threshold, revisions move to + `users/{uid}/drawings/{id}/revisions/{revId}` — one Firestore document each, + so the 900 kB cap applies per revision rather than to their sum. Needs a + `firestore.rules` addition. The `.pnid` file is uncapped and always carries + everything, so a downloaded project is never partial. + +--- + +## 3. Navigation & UX architecture + +This comes **first**, in the release before any module needs it. Five new +modules cannot hang off a toolbar that already scrolls (finding #8), and +retrofitting navigation after the fact is how tools become unusable. + +### 3.1 The workspace rail + +A 48 px vertical rail on the left edge. It replaces the single `HMI ⇄` button +and the `workspace: 'pid' | 'hmi'` boolean with named, linkable routes. + +``` +┌─────┐ +│ ▣ │ Project dashboard · standards · revisions +│ ✎ │ Draw the P&ID editor ← default +│ ▦ │ Data index · lines · equipment · I/O +│ ✓ │ Checks ② the QA report +│ 🖨 │ Deliverables datasheets · loop diagrams · packages +│ ⊞ │ HMI HMI Studio +└─────┘ +``` + +Rules that keep it honest: + +- **Draw stays the default and stays untouched.** Someone who only wants to draw + gains a 48 px strip and loses nothing. `App.tsx`'s current layout survives + intact inside the Draw workspace. +- Each workspace is a route (`/app/draw`, `/app/data`, …) extending the existing + `src/routes.ts`, and each is a `lazy()` chunk — matching the split already used + for `EditorRoot` and `HmiWorkspace`, so the Draw bundle does not grow. +- Collapses to icons under 1100 px; `Ctrl+1..6` switches; labels in tooltips. +- **Badges are the only thing that ever pulls attention out of Draw**, and only + for counts that are actionable (criticals, pending reviews). No red dots for + information. + +### 3.2 The object inspector — the database's front door + +The user-facing promise is "click FT-101, see its engineering record." Deliver +that **in the existing property panel**, not a new window, by giving it tabs when +a tagged object is selected: + +``` +PROPERTIES ▸ +┌────────┬─────────────┬────────────┬─────────┐ +│ Symbol │ Engineering │ Where used │ History │ +└────────┴─────────────┴────────────┴─────────┘ +``` + +| Tab | Content | +|-----|---------| +| **Symbol** | Today's `NodeProps` verbatim — geometry, rotation, label, pins, cost. Nothing moves, nothing breaks. | +| **Engineering** | The record. Fields come from the active standard's catalog, so a company profile changes what is asked for. Empty required fields are marked, not hidden. | +| **Where used** | Every place this tag appears: sheets, loop, line, HMI screen, datasheet, loop diagram, I/O point, C&E row. **Every row is a jump.** | +| **History** | This tag's revision entries — what changed, when, in which rev, by whom. | + +**Why tabs and not a modal:** the drawing must stay visible while you read the +record. That is the entire point of the P&ID being the front door — a modal hides +the thing you are asking about. (The ISA-20 datasheet stays a modal, correctly: +it is a print-shaped form you complete and leave, not something you consult while +drawing.) + +*Where used* is the connective tissue. It is what makes the product feel like a +database rather than a longer form, and it is cheap — every relationship it +displays is already derivable today. + +### 3.3 Command palette (`Ctrl+K`) + +One entry point for a growing app, absorbing the existing `Ctrl+F`: + +``` +Ctrl+K + FT-101 → jump to the object (any workspace) + > new revision → run a command + ? untagged → run a saved query, results as a jump list +``` + +`Ctrl+F` keeps working as an alias so nobody's muscle memory breaks. This is the +single highest-leverage navigation investment: it makes every future module +reachable without ever touching the toolbar again. + +### 3.4 The drawer + +Reduce from three tabs to two: **Issues** (validation + advisor merged, grouped +by severity) and **Loops**. The full report lives in the Checks workspace; the +drawer is the glance version. + +Rationale: two views of the same findings at different depth is good design +(glance vs. work). Three tabs showing three overlapping lists is not — today +"Validation" and "Advisor" are the same engine split by an implicit severity flag. + +### 3.5 Navigation invariants + +Rules the whole plan obeys, so the app stays navigable as it grows: + +1. **Every engineering object is ≤2 actions away from anywhere.** `Ctrl+K`, type + the tag. +2. **Every finding, table row and report line is a jump, never just text.** + `locateCell()` (`ValidationPanel.tsx:24`) is already the primitive — extend it + to cross-workspace jumps rather than inventing a second mechanism. +3. **Nothing new is modal** unless it is a form you complete and leave. +4. **No workspace ever takes over on its own.** Badges pull; they never push. +5. **Every destructive or irreversible engineering action names its blast radius + before it runs** — "this affects 5 documents", "37 existing tags would violate + this standard". + +--- + +## 4. Phase 1 — make the existing product professional + +Five features. Each specified as: goal · data · UI · files · tasks · tests · +acceptance · risk. + +Order is not arbitrary — §6 explains why each depends on the last. + +--- + +### 4.0 — The navigation shell (prerequisite, ships first) + +**Goal.** Give the next five features somewhere to live, and improve navigation +on its own merits so the release stands up even if nothing follows it. + +**Data.** None. Pure UI and routing. + +**UI.** §3.1 rail, §3.2 inspector tabs (Symbol tab only at first — the others +appear as their features land), §3.3 command palette, §3.4 drawer merge. + +**Files** + +| File | Change | +|------|--------| +| `src/routes.ts` | `'/app/:workspace'`, default `draw`; back/forward; `navigate()` | +| `src/WorkspaceRail.tsx` | **new** — the rail, badges, `Ctrl+1..6` | +| `src/EditorRoot.tsx` | render rail + active workspace; lazy-load each | +| `src/App.tsx` | becomes `DrawWorkspace`; drop the `'pid'\|'hmi'` boolean | +| `src/panels/CommandPalette.tsx` | **new** — absorbs `SearchOverlay`, adds commands | +| `src/panels/SearchOverlay.tsx` | deleted; `findTag` reused by the palette | +| `src/panels/Drawer.tsx` | 3 tabs → 2 (Issues, Loops) | +| `src/panels/PropertyPanel.tsx` | inspector tab strip; `NodeProps` → Symbol tab | +| `src/app.css` | `--rail-w: 48px`; grid gains a rail column | +| `src/hmi/HmiWorkspace.tsx` | `onExit` → route change | + +**Tasks** + +1. Extend `routes.ts` to parse `/app/`; keep `/app` → `/app/draw`. +2. Build the rail; badge counts from a single shared selector (§7.1). +3. Lift `App.tsx`'s grid into `DrawWorkspace`; `EditorRoot` owns rail + outlet. +4. Command palette: tag jumps (reuse `findTag`), then a `Command[]` registry that + later features append to. +5. Merge Validation + Advisor into one Issues list grouped by severity. +6. Add the inspector tab strip with one tab; the rest slot in later. + +**Tests** + +- `tests/routes.test.ts` — extend: workspace routes parse, unknown → draw. +- `tests/panels/commandPalette.test.ts` — **new**: tag match, command match, + `Ctrl+F` alias. +- `e2e/navigation.spec.ts` — **new**: rail switches workspace, URL changes, back + button returns, `Ctrl+K` opens and jumps. +- Existing `e2e/*.spec.ts` — audit for `/app` assumptions; `happy-path.spec.ts` + and `hmi.spec.ts` both navigate and will need the new route. + +**Acceptance.** A user who only draws notices a 48 px rail and a better `Ctrl+F`. +No drawing workflow changed. All existing e2e specs pass. + +**Risk.** Touching `App.tsx` and the CSS grid risks layout regressions in the +most-used screen. Mitigate: `e2e/screenshot.spec.ts` already exists — capture +baselines before the change and diff after. + +--- + +### 4.1 — The engineering database + +**Goal.** Every tagged object has a record; clicking it on the drawing opens +that record; the record survives redrawing, renaming, and deletion. + +**Data.** §2.1 `registry`, §2.3 invariants, §2.4 migration to schemaVersion 5. + +**UI.** Inspector *Engineering* and *Where used* tabs (§3.2). + +The Engineering tab renders from the standard's field catalog, grouped: + +``` +FT-101 Flow Transmitter ● Draft +───────────────────────────────────────────────────── +IDENTITY + Service Feed water + Area / Unit Unit 100 + Loop FIC-101 → jump + Line 6"-CW-101 → jump + Location Field + +MEASUREMENT + Range 0 – 100 m³/h ⚠ required + Signal 4–20 mA HART + Process conn. 1/2" NPT + +DEVICE + Manufacturer — + Model — + + Datasheet ↗ Loop diagram ↗ HMI object ↗ +``` + +`⚠ required` comes from the standard, not from a hardcoded list — the same +mechanism the QA engine reads (§4.2), so the panel and the report can never +disagree about what is required. + +**Files** + +| File | Change | +|------|--------| +| `src/model/registry.ts` | **new** — types, `recordFor()`, `keyOf()`, move/copy/collide helpers | +| `src/model/types.ts` | `ProjectDoc.registry`, `schemaVersion: 5`; delete dead `attrs` | +| `src/model/migrate.ts` | `migrateV4`, read-fallback | +| `src/store/store.ts` | `setRecordField`, `setRecordStatus`, `purgeRecord`; invariants inside `setTag` / `deleteIds` / `setEdge` | +| `src/store/selectors.ts` | `buildIndex(doc)` (§7.1), `whereUsed(index, key)` | +| `src/panels/InspectorEngineering.tsx` | **new** | +| `src/panels/InspectorWhereUsed.tsx` | **new** | +| `src/panels/DatasheetEditor.tsx` | read/write the registry, not `node.datasheet` | +| `src/export/csv.ts` | index/line-list read registry fields | +| `src/persist/file.ts` | `serializeDoc(doc, {pretty})` | +| `src/cloud/sync.ts` | `buildPayload` → compact | + +**Tasks** + +1. `registry.ts` + `keyOf(node)` / `keyOf(edge)` canonicalisation. +2. `ProjectDoc.registry`; bump to schemaVersion 5; `migrateV4` with fallback. +3. Store actions and the four invariants (§2.3), each with a test. +4. `buildIndex(doc)` — one pass producing nodes-by-id, edges-by-node, tags, + loops, records, neighbours. Everything downstream consumes this. +5. `whereUsed()` over the index: sheets, loop, line, HMI screen, deliverables. +6. The two inspector tabs. +7. Point `DatasheetEditor` and the CSV exports at the registry. +8. Compact cloud serialisation. + +**Tests** + +- `tests/model/registry.test.ts` — **new**: rename moves; rename onto an existing + key collides and does not merge; delete orphans rather than destroys; paste + does not copy records; copy-on-shared-tag. +- `tests/model/migrate.test.ts` — extend: a v4 doc with `node.datasheet` migrates + losslessly; untagged datasheets are parked, not dropped. +- `tests/store/registry.test.ts` — **new**: every mutation is a single undo step. +- `tests/export/csv.test.ts` — extend: index rows carry registry fields. +- `tests/cloud/sync.test.ts` — extend: `buildPayload` is compact; `sizeBytes` + drops ~50% for the refinery sample. +- `e2e/engineering.spec.ts` — **new**: tag → fill a field → delete the symbol → + redraw with the same tag → the field is still there. + +**Acceptance.** Deleting and redrawing FT-101 preserves its record. Renaming +FT-101 → FT-102 carries it. The instrument index CSV contains registry fields. +Cloud payload for the refinery sample is ~10 kB, not ~20 kB. + +**Risk.** The tag-keyed choice forces equipment to be tagged (§2.2). Mitigate +with the auto-tag fix in §4.2 and by shipping the equipment tag format in the +default standard. + +--- + +### 4.2 — The QA engine + +**Goal.** Replace 7 hardcoded checks and a one-fix advisor with a configurable +rule engine that produces an engineering QA report an engineer would sign. + +**Data.** + +```ts +// src/validate/rules.ts (new) +export interface Rule { + id: string + title: string + severity: 'critical' | 'warning' | 'info' + discipline: 'tagging' | 'topology' | 'process' | 'instrumentation' | 'data' | 'standard' + /** Runs over the shared index — never re-walks the doc. */ + run(ix: ProjectIndex, std: StandardProfile): RuleFinding[] +} + +export interface RuleFinding { + ruleId: string + /** Stable across redraws: rule + entity, not rule + node id. */ + key: string + entityKey: string + message: string + targetId?: string // node/edge id for the jump + sheetId?: string + fix?: { label: string; apply(): void } +} + +// ProjectDoc +qa?: { ignored: Record } +``` + +Four changes from today that matter: + +1. **Severity is explicit**, replacing `severity?: 'suggestion'` where absence + meant error (finding #5). +2. **Findings key on `ruleId + entityKey`, not node id** — so an ignore survives + a redraw, and a finding can be tracked across revisions. +3. **Any rule can offer a fix.** `FixSpec` today is a union of exactly one + member (`src/validate/suggest.ts:10`); it becomes a closure the rule returns. +4. **Ignore carries a reason and an author.** An engineer must be able to say + "yes, intentional" and have it stick and be auditable — this is the single + feature that decides whether a QA report gets used or ignored wholesale. + +**Rule catalogue for Phase 1** — 24 rules, 12 of which exist in some form today: + +*Critical* +`duplicate-tag`✓ · `invalid-letters`✓ · `incompatible-connection`✓ · +`broken-offpage-link`✓ · `no-fail-position`✓→promoted · `no-relief-device`✓→promoted · +`transmitter-no-receiver`✓→promoted · `orphan-record`★ · `tag-format-violation`★ · +`line-number-format-violation`★ + +*Warning* +`missing-tag`✓ · `dangling-end`✓ · `duplicate-line-number`✓ · +`controller-no-final-element`✓ · `required-field-empty`★ · `line-no-spec`★ · +`line-no-service`★ · `equipment-no-record`★ · `instrument-not-in-loop`★ + +*Info* +`duplicate-parallel-line`✓ · `valve-tag-on-bubble`✓ · `needs-ip-converter`✓(has fix) · +`not-in-datasheet-package`★ · `tag-numbering-gap`★ + +✓ exists · ★ new · promoted = exists as advice, becomes an error when the +standard demands it + +**Fixes shipping in Phase 1:** assign next free tag · set fail position · +link off-page connector · purge orphan record · renumber duplicate tag · +adopt line service from the connected run · insert I/P converter (exists). + +**UI.** The Checks workspace: + +``` +ENGINEERING QA Sheet: all ▾ Discipline: all ▾ [Re-run] + +🔴 CRITICAL · 2 + FT-101 has no receiving instrument in loop 101 [Fix ▾] [Open] [Ignore] + XV-204 has no defined fail position [Fix ▾] [Open] [Ignore] + +🟠 WARNING · 3 + PT-301 — required field "Calibrated range" is empty [Open] [Ignore] + … + +🔵 INFO · 2 ▸ collapsed + +⊘ IGNORED · 4 ▸ collapsed +``` + +Grouping is by **severity first, discipline second** — an engineer triages by +"what blocks issue", not by "what kind of check". Filters for sheet and +discipline. Ignored items stay visible in a collapsed group; hidden ignores rot. + +The drawer's Issues tab shows the same findings, criticals only, as a glance. + +**Files** + +| File | Change | +|------|--------| +| `src/validate/rules.ts` | **new** — `Rule`, `RuleFinding`, registry | +| `src/validate/rules/*.ts` | **new** — one module per discipline | +| `src/validate/engine.ts` | **new** — `runRules(ix, std, ignored)` | +| `src/validate/checks.ts` | ported into rules; kept as a thin adapter for one release | +| `src/validate/suggest.ts` | ported into rules; advisor severity → `info` | +| `src/assist/fixes.ts` | fixes become rule-owned closures; `insert-ip` moves | +| `src/store/selectors.ts` | one memoised `useQa()` (§7.1) | +| `src/workspaces/ChecksWorkspace.tsx` | **new** | +| `src/panels/Drawer.tsx` | Issues tab reads `useQa()` | +| `src/panels/StatusBar.tsx` | reads `useQa()`, shows critical count | + +**Tests** + +- `tests/validate/rules/*.test.ts` — **one file per rule**, positive and negative. + The existing `tests/validate/checks.test.ts` patterns port directly. +- `tests/validate/engine.test.ts` — **new**: ignores suppress; ignore survives a + redraw (key stability); severity overrides from the standard apply; `off` + disables. +- `tests/assist/fixes.test.ts` — extend: every shipped fix is one undo step and + clears its own finding. +- `e2e/qa.spec.ts` — **new**: create a violation → appears critical → apply fix → + clears; ignore with a reason → moves to Ignored and persists across reload. + +**Acceptance.** 24 rules run; every fix is one undo step and clears its finding; +an ignore survives delete-and-redraw; a full pass over a 500-item project stays +under 16 ms (§7.2). + +**Risk.** Promoting advice to critical will make existing drawings light up red +on upgrade. Mitigate: promotions are **off by default** in the built-in standard +and switched on in a company profile; ship a one-time "N findings are new in this +version — review or ignore all" banner. + +--- + +### 4.3 — Company standards + +**Goal.** The validator checks against *your company's* rules, not generic ones. +This is what turns a checker into something a company will pay for. + +**Data.** + +```ts +// src/model/standard.ts (new) +export interface StandardProfile { + id: string + name: string // 'Company XYZ — Rev 3' + tagFormat: { + pattern: 'LL-NNN' | 'LL-NNNA' | 'AREA-LL-NNN' + separator: '-' | '' + numberStart: 100 | 1 + digits: 3 | 4 + perArea?: boolean + } + lineNumber: { + order: ('size' | 'service' | 'spec' | 'seq')[] // 6"-CW-150-001 vs 6"-150-CW-001 + separator: string + sizeUnit: 'in' | 'DN' + } + /** Field ids that must be filled before a status can advance. Drives BOTH + * the inspector's ⚠ markers and the required-field-empty rule. */ + required: { instrument: string[]; equipment: string[]; line: string[] } + conventions: { + valveFailPosition: 'required' | 'optional' + defaultSignal: string + defaultLocation: 'field' | 'panel' + sheetSize: SheetSize + titleBlock?: string + } + fluids?: Fluid[] + severityOverrides?: Record +} + +// ProjectDoc +standard?: StandardProfile +``` + +Stored **in the document**, so a `.pnid` is self-describing and a reviewer opening +it checks against the same rules the author did. Also exportable as +`.ipdstd.json` so one company file seeds every project. + +`settings.numberStart`, `settings.tagSeparator` and `doc.fluids` migrate in and +are read as fallback for one release (§2.4). + +**UI.** Project workspace → Standards. A settings page, not a modal. + +The one detail that decides adoption: **a live impact preview before you apply.** + +``` +Tag format LL-NNN ▾ ⚠ 37 existing tags would violate this +Line numbering size-service-spec-seq ▾ ✓ all 84 lines conform +Required (instr.) [Service ×] [Range ×] [Signal ×] [+ add] + ⚠ 112 instruments would gain a warning + + [Preview changes] [Apply] +``` + +Without that preview nobody dares turn a standard on in a live project, and the +feature dies. `[Preview changes]` opens the QA report filtered to +would-be-new findings. + +**Files** + +| File | Change | +|------|--------| +| `src/model/standard.ts` | **new** — type, `DEFAULT_STANDARD`, `validateProfile()` | +| `src/model/types.ts` | `ProjectDoc.standard` | +| `src/model/migrate.ts` | fold legacy settings in | +| `src/isa/tag.ts` | `formatTag`/`parseTag` take the profile | +| `src/isa/autonumber.ts` | `numberStart`/digits/per-area from the profile | +| `src/validate/rules/standard.ts` | **new** — format + required-field rules | +| `src/workspaces/StandardsPage.tsx` | **new** | +| `src/panels/FluidsDialog.tsx` | reads profile fluids | +| `src/persist/standard.ts` | **new** — import/export `.ipdstd.json` | + +**Tests** + +- `tests/model/standard.test.ts` — **new**: defaults; round-trip; a malformed + profile is rejected with a readable message. +- `tests/isa/tag.test.ts` — extend: `AREA-LL-NNN` parses and formats; 4-digit. +- `tests/validate/rules/standard.test.ts` — **new**: format violations by + profile; `required` drives `required-field-empty`; `off` disables a rule. +- `e2e/standards.spec.ts` — **new**: change the tag format → preview reports the + count → apply → the QA report matches the preview exactly. + +**Acceptance.** Two profiles over one drawing produce two different QA reports. +The preview count equals the post-apply count. A `.ipdstd.json` round-trips. + +**Risk.** Changing the tag format on a live project is destructive if it +auto-renames. It must **not** — the standard *validates*, and renaming is an +explicit fix per object (or a reviewed bulk fix). Never silently rewrite tags. + +--- + +### 4.4 — Project dashboard + +**Goal.** Opening a project answers "where does this stand?" in one screen. + +**Data.** None new. Every number is derived from `buildIndex` + the standard + +`projectCost` (which already exists, `src/model/costs.ts:331`). + +**UI.** The Project workspace landing page. Every tile is a jump. + +``` +FEED WATER UNIT — REV 3 Issued 12 Aug 2026 + + P&IDs 3 Instruments 386 Lines 214 Equipment 71 + ────────────────────────────────────────────────────────────── + ENGINEERING COMPLETENESS 82% + ████████████████████████████░░░░░░ + Instruments 94% Lines 71% Equipment 58% → Data + + QUALITY DELIVERABLES + 🔴 4 critical Datasheets 342 / 386 + 🟠 12 warnings → Checks Loop diagrams 301 / 386 → Deliverables + 🟢 370 clean I/O assigned — + + BUDGET RECENT REVISIONS + $2.4M of $3.0M ████████░░ Rev 3 12 Aug FT-101 range … + installed ×3.0 → Budget Rev 2 04 Aug LT-204 added → Revisions +``` + +**Completeness** is the only non-trivial computation: per kind, the fraction of +`standard.required` fields that are filled, averaged over objects of that kind. +It depends on §4.3, which is why the dashboard follows standards in the sequence. + +**Files** + +| File | Change | +|------|--------| +| `src/model/health.ts` | **new** — `projectHealth(ix, std)` returning every tile's numbers | +| `src/workspaces/ProjectWorkspace.tsx` | **new** | +| `src/workspaces/tiles/*.tsx` | **new** — one component per tile | + +**Tests** + +- `tests/model/health.test.ts` — **new**: completeness maths incl. the + zero-objects and no-required-fields edge cases (must not read 0% or NaN); + counts match the index. +- `e2e/dashboard.spec.ts` — **new**: every tile navigates to the right workspace + with the right filter applied. + +**Acceptance.** Numbers agree with the underlying workspaces exactly — a +dashboard that disagrees with the report it links to is worse than none. + +**Risk.** Low. The main trap is a "completeness" number that is quietly +meaningless; guard it with the edge-case tests and show "—" rather than a +percentage when the denominator is zero. + +--- + +### 4.5 — Revisions & change tracking + +**Goal.** Answer "what changed between Rev 3 and Rev 4?" in engineering terms. + +**Data.** + +```ts +// src/model/revision.ts (new) +export interface Revision { + id: string + number: string // '0', 'A', '1' + issuedAt: string + issuedBy: string + reason: string + changes: Change[] + /** Geometry churn, summarised — never itemised. */ + geometryNote?: string // '3 symbols repositioned on Sheet 2' +} + +export interface Change { + entityKey: string // 'FT-101' | '6"-CW-101' | 'sheet:S1' + kind: 'added' | 'removed' | 'modified' + field?: string + before?: string + after?: string +} + +// ProjectDoc +revisions?: Revision[] +/** Entity-level snapshot of the last issued revision, for diffing. */ +revBaseline?: Record> +``` + +**The design call that matters: revisions diff engineering data, not drawing +coordinates.** Moving a symbol 8 px is not an engineering change and must never +appear in a revision report. The baseline is the derived *entity table* — tags, +record fields, line numbers, connections, loop membership — not `x`/`y`. +Geometry gets one summary line and no detail. + +**The storage design, forced by the measurement in §0:** full snapshots do not +fit (2 at 400 items, 0 at 1000). So: + +- A revision stores only its diff. A typical rev touches 5–50 entities → 2–8 kB. +- One `revBaseline` is kept (≈40–60 kB at 400 items); older states are + reconstructed by replaying diffs backwards. +- Above a threshold, revisions move to + `users/{uid}/drawings/{id}/revisions/{revId}` (one Firestore doc each) so the + 900 kB cap applies per revision. `firestore.rules` extended accordingly. +- The `.pnid` file always carries everything — it has no cap. + +**UI.** Project → Revisions, plus the inspector's History tab. + +``` +REV 3 → REV 4 18 changes [Export PDF] + + ADDED · 4 + 🟢 LT-204 Level Transmitter Sheet 2 → jump + 🟢 6"-CW-118 Cooling water line Sheet 2 + + MODIFIED · 11 + 🟡 FT-101 Calibrated range 0–100 m³/h → 0–150 m³/h + 🟡 FIC-101 Location Field → Control Room + 🟡 XV-204 Fail position FO → FC + + REMOVED · 3 + 🔴 XV-301 Shutdown valve was Sheet 1 + + 3 symbols repositioned on Sheet 2 (no engineering change) +``` + +Issuing a revision opens a dialog that states the blast radius before it commits +(invariant #5): how many objects changed, how many criticals are open, and which +deliverables will be marked stale. + +**Files** + +| File | Change | +|------|--------| +| `src/model/revision.ts` | **new** — types, `entitySnapshot(ix)`, `diffSnapshots(a,b)` | +| `src/model/types.ts` | `revisions`, `revBaseline` | +| `src/store/store.ts` | `issueRevision(number, reason)` | +| `src/workspaces/RevisionsPage.tsx` | **new** — list + compare | +| `src/panels/InspectorHistory.tsx` | **new** | +| `src/panels/IssueRevisionDialog.tsx` | **new** | +| `src/export/revisionReport.ts` | **new** — the compare view as PDF | +| `src/cloud/sync.ts` | revisions subcollection above threshold | +| `firestore.rules` | revisions subcollection rule | + +**Tests** + +- `tests/model/revision.test.ts` — **new**: a pure move produces zero changes; + a field edit produces exactly one; add/remove classified correctly; backward + replay reconstructs an older state exactly (golden fixture). +- `tests/cloud/sync.test.ts` — extend: a 40-revision project stays under the cap; + the split threshold triggers. +- `e2e/revisions.spec.ts` — **new**: edit a range → issue Rev 1 → compare shows + exactly that change and no geometry noise. + +**Acceptance.** Repositioning symbols produces no revision entries. A 40-revision, +400-item project saves to the cloud. Backward replay is byte-exact against the +golden fixture. + +**Risk.** The diff is the hardest correctness problem in Phase 1 — a wrong +revision report is worse than no revision report. Mitigate with golden-file tests +and by shipping revisions **last**, once the registry has settled. + +--- + +## 5. Phase 2 — make it save engineering hours + +Phase 1 makes the data exist and be trustworthy. Phase 2 makes it *do work*. +Specified to design detail; each becomes a Phase-1-depth spec when scheduled. + +### 5.1 Deliverable synchronisation + +**The claim:** change FT-101's range and the tool says *"this affects 5 +documents"* and regenerates them. + +**What makes it real:** deliverables must become **tracked objects**, not +one-shot downloads. Today `downloadInstrumentIndex()` writes a file and forgets. + +```ts +interface IssuedDeliverable { + id: string + type: 'instrument-index' | 'line-list' | 'datasheet' | 'loop-diagram' | 'io-list' + scope?: string // the tag, for per-object deliverables + generatedAt: string + atRev: string + /** Hash of the inputs it was generated from — staleness without storing a copy. */ + inputHash: string +} +``` + +Plus a declarative dependency map: + +```ts +const DELIVERABLE_INPUTS: Record string[]> +// 'datasheet' depends on registry[key].fields + tag + loop +// 'loop-diagram' depends on every member of the loop + their signal fields +``` + +Then staleness is a hash comparison, and *"3 documents require regeneration"* is +a query, not a guess. **Regenerate all** is a batch over the stale set. + +**Sequencing note:** this depends on Phase 1's registry (inputs) and revisions +(`atRev`). It is the single most valuable Phase 2 item and should go first. + +### 5.2 Editable engineering tables + +The Data workspace: Instrument Index · Line List · Equipment List · I/O List as +sub-tabs. A plain grid — no new dependency — with inline edit writing straight to +the registry, column sets from the standard, filter/sort/group, and CSV +round-trip so a bulk fill can happen in Excel and come back. + +Two hard parts: **paste-a-block** semantics (must be one undo step) and +**virtualisation** past ~2000 rows. + +This is where the Line List becomes a real object (material, rating, design P/T, +insulation, tracing, from/to) and the Equipment List appears for the first time — +both are `EntityKind`s the registry already supports, so this is UI, not schema. + +### 5.3 Review workflow + +Comment threads keyed on `entityKey`, and a status machine: + +``` +draft → in-check → in-review → approved → issued +``` + +with the transition to `approved` **gated on zero open criticals** — the QA +engine finally has teeth. Comments carry author, timestamp, and +open/commented/resolved state. + +Single-user first (named roles, no server): one person can still run +design → check → review on their own work, and the audit trail is real. Real +multi-user follows in Phase 3 with the sync layer. + +### 5.4 I/O engineering + +An `io` block on the instrument record — signal type, rack, slot, channel, card, +JB, cable — plus a card-configuration model and auto-assignment (next free +channel on a compatible card). Produces the I/O list deliverable, and sets up the +cable/JB schedules in Phase 3. + +This is the item that makes IPD Studio legible to DCS/PLC engineers, and it is +mostly data-entry UI over an existing spine once §5.2 lands. + +### 5.5 Project assistant + +**Grounded retrieval over the entity index — not a chatbot over the drawing.** + +``` +"Show all pressure instruments connected to steam lines" + → parse to a structured filter over buildIndex() + → 7 results, as a jump list + +"Which loops are missing a controller?" → runs an existing rule +"What changed in revision 4?" → reads the revision diff +"Find instruments without datasheets" → a registry query +``` + +Every answer is a **jump list into real objects**, never prose about them. If a +question cannot be answered by a query over the model, the assistant says so +rather than generating. That constraint is the whole design: a P&ID assistant +that hallucinates a tag is worse than no assistant, because the output looks +exactly like an answer. + +--- + +## 6. Phase 3 — make it hard to replace + +Directional. What matters here is the decisions, not the schedule. + +| Item | The decision that matters | +|------|---------------------------| +| **PFD / BFD → P&ID** | DEXPI 2.0 (Oct 2025) covers BFD, PFD and P&ID in one specification. Target it *after* the registry exists — DEXPI 2.0's value is its attribute model, and today there is nothing in IPD Studio for those attributes to map onto. The current export is Proteus-4.2-shaped geometry. | +| **Control logic** | Extend the loop object with strategy (PID/cascade/ratio/feedforward/split-range), tuning, and interlocks. The HMI simulator already runs PI loops from ISA tags (`src/hmi/sim/engine.ts`) — this connects the engineering model to a simulator that already exists rather than building one. | +| **SIS / Cause & Effect** | SIF objects, voting, trip conditions; the C&E matrix as a first-class deliverable with staleness (§5.1) so it flags when the P&ID moves under it. Highest-value single module for process-safety buyers. | +| **MOC** | Sits directly on Phase 1 revisions + Phase 2 review. Cheap once both exist; incoherent before. | +| **Collaboration** | Needs a server, which breaks local-first — so it must be opt-in and additive. **Note the registry helps here:** a flat, key-addressed `Record` is far more mergeable than nested `sheets[].nodes[]` arrays. Phase 1's data shape is deliberately CRDT-friendly. | +| **Enterprise** | SSO, RBAC, audit, on-prem. Gate this behind real demand; it is months of work that produces no engineering value on its own. | + +--- + +## 7. Cross-cutting concerns + +### 7.1 One index, one memo + +Today `runChecks` runs **3×** and `runSuggestions` **2×** per document change +(finding #7), each rebuilding its own maps, each walking every sheet. + +Fix, in §4.0: + +```ts +// src/store/selectors.ts +export function useIndex(): ProjectIndex // memoised once on doc identity +export function useQa(): QaReport // memoised on (index, standard, ignored) +``` + +`StatusBar`, `Drawer`, `ChecksWorkspace`, the dashboard and the rail badge all +read the same memo. One pass, one result, no possibility of two panels disagreeing. + +`buildIndex(doc)` produces, in a single walk: nodes by id · edges by node · +tags → nodes · loops · records by key · neighbour lists · port kinds. Every rule +and every deliverable consumes it instead of re-walking. + +### 7.2 Performance budget + +| Operation | Budget | Why | +|-----------|--------|-----| +| `buildIndex` on 500 items | < 8 ms | runs on every doc change | +| Full rule pass, 24 rules, 500 items | < 16 ms | one frame | +| Inspector tab switch | < 50 ms | feels instant | +| Dashboard render | < 200 ms | it is a landing page, not a keystroke | + +If the rule pass exceeds budget, move it to a Web Worker before optimising rules +individually — `ProjectDoc` is plain JSON and therefore structured-cloneable, so +this is a cheap escape hatch. Add a `tests/perf/` guard that fails CI on a +regression against a generated 500-item fixture. + +### 7.3 Migration & compatibility + +- One schema bump for all of Phase 1: **4 → 5**, landing with §4.1. +- Read-fallback for one release (§2.4): new readers accept old shapes. +- `loadDoc` already tolerates v2/v3 by passing them through; follow that pattern. +- A v5 file opened by a v4 build loses the registry but keeps the drawing — + degraded, never destructive. +- Every migration gets a golden-file test with a real fixture, not a synthetic one. + +### 7.4 Licensing & entitlement — the gap + +**There is no entitlement mechanism in the product at all** (finding #10). If +Phase 1 is meant to be the paid tier, that is prerequisite work, and it needs an +honest premise: + +> Client-side feature gating in a source-available repository is **advisory**. +> Anyone can build from source with the check removed. The enforceable boundary +> is the **service**, not the bundle. + +So the defensible split is: + +| Tier | Boundary | +|------|----------| +| **Community** | The full local editor. Draw, validate, export, local files. Enforced by the licence text, not by code. | +| **Professional** | Account-tier features that run through the cloud: sync across devices, revision storage, deliverable registry, standards library, assistant. Enforced by Firestore rules against a `users/{uid}/plan` field — server-side, real. | +| **Enterprise** | Organisation accounts, RBAC, audit, on-prem. | + +Design implication for Phase 1: features whose value is inherently *cloud-shaped* +(revision storage, shared standards, deliverable registry) are the ones with a +real commercial boundary. Features that are pure local computation (the QA +engine, the dashboard) can be gated only advisorily — so treat them as +community-tier value that makes the product worth adopting, and monetise the +service around them. Trying to lock the QA engine client-side will cost more +engineering than it protects. + +### 7.5 Testing strategy + +Follow what the repo already does — `vitest` in `tests//`, Playwright in +`e2e/`, the `window.__pid` store hook for e2e setup. + +Additions: + +- **One test file per rule.** 24 rules, 24 files, positive + negative each. This + is the difference between a QA engine that is trusted and one that is not. +- **Golden fixtures** for migration and revision diffs — real `.pnid` files + committed under `tests/fixtures/`. +- **Invariant tests** for the registry (§2.3) — these encode the decisions that + are easiest to break accidentally. +- **A perf fixture** — generated 500-item project, guarding §7.2. +- **Screenshot baselines** before the §4.0 layout change (`e2e/screenshot.spec.ts` + already exists). + +--- + +## 8. Sequencing + +One feature per minor release. Every release ships value on its own — nothing +here requires the user to wait for a later one to benefit. + +| Release | Ships | Depends on | Standalone value | +|---------|-------|-----------|------------------| +| **v0.14.0** | Navigation shell (§4.0) + compact cloud payload | — | Better navigation, `Ctrl+K`, half-size cloud saves | +| **v0.15.0** | Engineering registry + inspector (§4.1) | v0.14 | Records survive redraws; data has a home | +| **v0.16.0** | QA engine + Checks workspace (§4.2) | v0.15 | 24 rules, fixes, auditable ignores | +| **v0.17.0** | Company standards (§4.3) | v0.16 | The validator checks *your* rules | +| **v0.18.0** | Project dashboard (§4.4) | v0.17 | One screen answers "where does this stand" | +| **v0.19.0** | Revisions + compare (§4.5) | v0.15, v0.18 | Rev-to-rev change reports | +| **v0.20.0+** | Phase 2 (§5), starting with deliverable sync | Phase 1 | — | + +**Why this order, precisely:** + +- **Shell first** because five modules cannot hang off a full toolbar, and + retrofitting navigation is how tools become unusable. +- **Registry before QA** because 9 of the 24 rules check record data that does + not exist yet. +- **Standards after QA** because a standard is expressed *as rule configuration* + — building it first would mean designing config for an engine that isn't there. +- **Dashboard after standards** because completeness is measured against + `standard.required`; before that it has no denominator. +- **Revisions last in Phase 1** because they diff the registry, and the registry + shape must have settled first. It is also the hardest correctness problem here + (§4.5 risk), so it benefits from landing on stable ground. + +--- + +## 9. What not to build + +Stated as decisions, with reasons, so they don't get relitigated: + +| Not this | Why | +|----------|-----| +| **More symbols** | 195+ is already past the point of diminishing returns. The gap is not "which symbol", it is "what does this symbol know". | +| **A generic AI chatbot** | An assistant that cannot ground an answer in the model will invent tags. §5.5 is retrieval over the index or it is nothing. | +| **3D, animations, themes, mobile** | None of these move an engineering deliverable. | +| **Real-time collaboration before the data model** | Merging nested `sheets[].nodes[]` arrays is a research problem; merging a flat keyed registry is not. Do the data model first — Phase 1 makes collaboration *easier*, not later. | +| **Client-side feature locks** | Advisory in a source-available repo (§7.4). Spend the effort on the service boundary instead. | +| **Auto-renaming tags when a standard changes** | Silently rewriting engineering identities is the fastest way to lose a user's trust. Validate; offer per-object fixes; never rewrite unasked. | +| **Itemising geometry in revision reports** | A rev report full of "moved 8 px" is a rev report nobody reads (§4.5). | + +--- + +## 10. The shape of the thing, once Phase 1 lands + +``` + ONE PLANT MODEL + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + THE DRAWING THE REGISTRY THE STANDARD + sheets · nodes tag-keyed records formats · required + · edges · status · owner · severities + │ │ │ + └─────────┬───────────┴──────────┬──────────┘ + │ │ + QA ENGINE DERIVED VIEWS + 24 rules · fixes index · lines · equipment + · auditable ignores loops · I/O · costs + │ │ + └──────────┬───────────┘ + │ + DELIVERABLES + datasheets · loop diagrams · CSV + DEXPI · DXF · PDF · HMI + │ + REVISIONS + diffed on engineering data, + never on coordinates +``` + +The drawing stays the front door. Everything behind it finally has somewhere to +live, one engine that checks it, and a record of how it changed. + +--- + +*Grounded in an audit of IPD Studio v0.13.0 — 19,357 LOC across 137 source +files — with storage figures measured against `examples/sample-refinery-unit.pnid.json`.* diff --git a/docs/LICENSE-ENFORCEMENT.md b/docs/LICENSE-ENFORCEMENT.md index 3a37237..056a46c 100644 --- a/docs/LICENSE-ENFORCEMENT.md +++ b/docs/LICENSE-ENFORCEMENT.md @@ -1,14 +1,40 @@ -# Tracking copies & enforcing the AGPL (maintainer guide) +# Tracking copies & enforcing the licence (maintainer guide) -How to find out if someone took IPD Studio commercial without honoring the -AGPL or buying a commercial license — and what to do about it. +How to find out if someone is using IPD Studio commercially without a paid +licence — and what to do about it. + +Since v0.13.0 the licence is **PolyForm Noncommercial 1.0.0**. The line to +enforce is simpler than it was under the AGPL: **any commercial use at all +requires a paid licence.** You no longer have to prove they modified the code +or failed to publish source — commercial use *is* the violation. + +## 0. Know what you're actually claiming + +| Situation | Under AGPL (≤ v0.12.1) | Under PolyForm (≥ v0.13.0) | +|---|---|---| +| Company uses it internally, unmodified | Permitted | **Violation** | +| Consultant draws client P&IDs with it | Permitted | **Violation** | +| Paid training run on HMI Studio | Permitted | **Violation** | +| Hosted, modified, source published | Permitted | **Violation** | +| Student / university / NGO / govt use | Permitted | Permitted | + +⚠️ **Anyone can still lawfully use v0.12.1 or earlier under the AGPL, +commercially.** Before sending any notice, establish *which version* they +have. Check their bundle for the `@license` banner (it carries the version) or +for post-0.13.0 features. Accusing someone over a legitimately AGPL-licensed +copy is the one mistake that costs you credibility. ## 1. Fingerprints already in the code These strings are distinctive enough that a copy almost certainly still contains some of them, even after light rebranding. Search engines and -GitHub code search will find them in minified bundles too: +GitHub code search find them in minified bundles too: +- `@license IPD Studio v` — the build banner, injected by `vite.config.ts` + and preserved through minification. Its presence dates their copy. +- `SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0` — on all 137 source + files. Present in any copy of the *source*; its deliberate removal is + evidence of wilfulness, which matters for damages. - `application/x-pnid` (the file MIME type) - `.pnid` file extension + `schemaVersion` JSON shape - `instr.bubble`, `comp.centrifugal`, `fit.pulsation-dampener` (symbol ids) @@ -22,43 +48,60 @@ GitHub code search will find them in minified bundles too: [`application/x-pnid`](https://github.com/search?q=%22application%2Fx-pnid%22&type=code) and [`instr.bubble`](https://github.com/search?q=%22instr.bubble%22+%22hmi-spin%22&type=code) — anything outside `Coldbari/IPD-Studio` and its forks deserves a look. - Forks themselves are fine (that's the license working); look for - *detached* copies with the history stripped. + Note the two pre-existing forks (`GonzaloMig`, `dk009dk`) hold perpetual + AGPL rights to the code as it stood at fork time; they are not violations. - **Google / Bing:** `"IPD Studio"`, `"P&ID editor" "hmi-spin"`, - `intext:"application/x-pnid"`. -- **Google Alerts** (one-time setup at google.com/alerts): create alerts - for `"IPD Studio"` and `"pnid" P&ID editor` — findings arrive by email. -- **npm:** search npm for republished packages containing the symbol - library (`npm search pnid p&id`). + `intext:"application/x-pnid"`. Add `-github.com` to surface commercial sites. +- **Google Alerts** (one-time setup at google.com/alerts): alerts for + `"IPD Studio"` and `"pnid" P&ID editor` — findings arrive by email. +- **npm:** search for republished packages containing the symbol library + (`npm search pnid p&id`). +- **LinkedIn / job ads:** engineering consultancies naming the tool in + service descriptions are the highest-value leads — they are commercial by + definition and easy to convert. ## 3. Checking a suspect commercial site 1. Open their app, view page source / the JS bundles (DevTools → Sources). 2. Search the bundles for the fingerprints above. Minification does not - remove string literals. -3. If it's a hit and they modified the code: AGPL §13 requires them to - offer their **complete corresponding source** to their users. No source - offer → violation. + remove string literals, and the `@license` banner is preserved by design. +3. **Establish the version.** The banner carries it. If it predates v0.13.0, + they may be lawfully on the AGPL — check whether they are also *hosting a + modified* copy without publishing source, which was the AGPL violation. +4. Screenshot and archive (web.archive.org) before contacting them. Evidence + disappears the moment you send an email. ## 4. Escalation ladder (cheapest first) -1. **Friendly email** — most violations are ignorance. Point at - `COMMERCIAL-LICENSE.md`; offer the paid license. This converts - violators into customers. -2. **GitHub DMCA takedown** (if hosted on GitHub): +1. **Friendly email** — most violations are genuine ignorance, and this + licence is newer than the project. Point at `COMMERCIAL-LICENSE.md`, offer + the paid licence, name a price. This converts violators into customers, + which is the actual goal. Do not lead with legal threats. +2. **Formal notice.** PolyForm §Violations gives them **32 days** from written + notice to come into full compliance, after which all their licences end + automatically. Send it in writing, dated, and keep a copy — that clock is + the strongest lever in the licence, and it only starts when you write. +3. **GitHub DMCA takedown** (if hosted on GitHub): https://github.com/contact/dmca — effective and free. -3. **Hosting provider abuse contact** (for non-GitHub hosting) — providers - act on copyright complaints. -4. **Software Freedom Conservancy** (https://sfconservancy.org) — advises - on AGPL enforcement, sometimes takes cases pro bono. +4. **Hosting provider abuse contact** (non-GitHub hosting) — providers act on + copyright complaints. 5. Lawyer letter — rarely needed; step 1 resolves most cases. +Note: the Software Freedom Conservancy assists with *open-source* licence +enforcement and will **not** take PolyForm cases. That backstop went away with +the AGPL — enforcement is now on you. + ## 5. What NOT to do -- Don't add phone-home telemetry to detect copies — it would betray the +- **Don't accuse before checking the version.** A pre-0.13.0 copy used + commercially is lawful. This is the one costly mistake. +- **Don't add phone-home telemetry to detect copies** — it would betray the local-first promise to legitimate users, and violators would strip it anyway. String fingerprints + search do the job. -- Don't demand more than the license grants: unmodified mirroring with the - license intact, forks on GitHub, and internal company use are all - **permitted** — enforcement is only for proprietary redistribution or - unpublished modified hosting. +- **Don't chase permitted users.** Students, universities, hospitals, + charities, and government bodies are explicitly licensed, regardless of how + they are funded. Leave them alone; they are the reason the licence exists in + this shape. +- **Don't demand more than the licence grants.** Noncommercial forks, mirrors + with the licence intact, and modifications for permitted purposes are all + allowed. diff --git a/docs/index.html b/docs/index.html index cd265f1..3f74b94 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,8 +3,8 @@ -IPD Studio — free browser P&ID editor + HMI simulator - +IPD Studio — browser P&ID editor + HMI simulator + @@ -66,7 +66,7 @@

IPD Studio

-

Intelligent P&ID editor + HMI training simulator — free, open source, entirely in your browser.

+

Intelligent P&ID editor + HMI training simulator — entirely in your browser. Free for personal, academic & nonprofit use.

▶ Launch the app ⭐ Star on GitHub @@ -81,8 +81,8 @@

IPD Studio

-

The $2,600/year tool, for $0

-

Browser diagramming tools treat a P&ID as clipart. Intelligent P&ID suites are desktop-only and cost thousands per seat. IPD Studio puts real engineering data behind the drawing — free forever under the AGPL.

+

The $2,600/year tool, free to learn on

+

Browser diagramming tools treat a P&ID as clipart. Intelligent P&ID suites are desktop-only and cost thousands per seat. IPD Studio puts real engineering data behind the drawing — free for students, researchers, and nonprofits, and fairly priced for the companies that bill for it.

🏭

195+ real ISA symbols

Instrument bubbles, control valves with actuators & fail actions, safety devices, flow elements, vessels, exchangers, solids handling — plus your own imported SVG symbols.

🏷️

Tags that mean something

Type FIC and the editor knows it's a Flow Indicating Controller. Loop numbers auto-assign, duplicates flag live, control loops derive from the tags.

@@ -106,10 +106,11 @@

From drawing to running plant

-

Free for engineers. Protected from freeloaders.

+

Free to learn on. Licensed to earn on.

-

Use it at work, free. Draw commercial P&IDs, run paid training — your drawings are yours. The AGPL-3.0 covers the software, not your documents.

-

Copy it, and you share back. Anyone redistributing or hosting a modified IPD Studio must publish their source. Want a closed-source or white-label version instead? A commercial license is available.

+

Free, no permission needed. Personal use, study and research, hobby projects, students and coursework, universities and labs, charities, hospitals, public research bodies and government institutions. Your drawings are always yours — the licence covers the software, not your documents.

+

Using it commercially? That needs a licence. Any use by or for a for-profit company — including internal use, paid client work, and paid operator training — requires a paid commercial licence. It is friendly, quick, and priced to scale. See the licensing page.

+

Source-available, not open source. The code is public so you can read it, learn from it, and verify exactly what it does — under the PolyForm Noncommercial 1.0.0 licence.

Want to contribute? Good first issues are waiting — the guide takes you from fork to merged PR.

@@ -124,7 +125,7 @@

Free for engineers. Protected from freeloaders.

Discussions ☕ Support
-
© 2026 Praharsh Nagpure · AGPL-3.0 · Not affiliated with the International Society of Automation
+
© 2026 Praharsh Nagpure · PolyForm Noncommercial 1.0.0 · Commercial use requires a licence · Not affiliated with the International Society of Automation
diff --git a/docs/superpowers/specs/2026-08-31-homepage-auth-cloud-design.md b/docs/superpowers/specs/2026-08-31-homepage-auth-cloud-design.md new file mode 100644 index 0000000..761f0fe --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-homepage-auth-cloud-design.md @@ -0,0 +1,131 @@ +# Homepage, accounts, and cloud drawings — design + +Date: 2026-08-31 +Status: approved, in implementation +Target version: v0.13.0 + +## Problem + +IPD Studio is a single-page editor served at `/`. There is no landing page, no +concept of a user, and drawings live only in the browser's IndexedDB or in a +file the user saves by hand. Someone who opens the app on a second machine +starts from nothing. + +Three things are wanted: + +1. A homepage that explains the product, styled after operonsolutions.com. +2. Registration and login. +3. Drawings saved to Firebase so a returning user finds their work. + +## Decisions taken + +| Decision | Choice | +|---|---| +| Anonymous access | Editor stays fully usable with no account. Sign-in only adds cloud drawings. | +| Routing | Homepage at `/`, editor at `/app`. | +| Storage | One Firestore document per drawing, doc JSON stored as a string field. | +| Sign-in methods | Email + password, and Google. | +| Out of scope | Teams, sharing, public links, real-time collaboration, thumbnails, billing. | + +## Licensing context + +The working tree is mid-migration from AGPL-3.0-only to **PolyForm +Noncommercial 1.0.0** (v0.13.0), with a paid commercial license alongside. The +homepage copy must reflect this: *source-available, free for noncommercial use, +commercial use requires a license.* It must not say "open source" or "AGPL". + +Every new source file carries the project's SPDX header. + +## Architecture + +### Routing — `src/routes.ts` + +No router dependency; three routes do not justify one. Firebase Hosting already +rewrites `**` to `index.html`, so real paths work. + +- `useRoute()` — reads `location.pathname`, subscribes to `popstate`. +- `navigate(path)` — `history.pushState` + notify. + +`main.tsx` renders `Home` at `/` and lazy-loads the editor at `/app`. Today +every visitor downloads JointJS, the symbol catalog and the HMI simulator +before reading a headline; after the split the homepage ships a small bundle +and the editor loads on demand. + +Editor-only side effects (autosave restore, `launchQueue` file handling) move +out of `main.tsx` into the editor route so they do not run for visitors. + +PWA `start_url` and the `.pnid` `file_handlers` action move from `/` to `/app`. + +### Homepage — `src/home/` + +Operon's section order, IPD Studio's existing dark palette (`#0d1220` ground, +`#4da3ff` accent, taken from `docs/index.html`). + +1. Nav — logo, Features, HMI Studio, Licensing, Docs, GitHub; Sign in; Open the editor +2. Hero — headline, subhead, dual CTA, editor screenshot +3. Three claims — validated ISA tags, loops derive themselves, deliverables generated +4. Features 01–04 — P&ID editor, HMI Studio, budget estimator, open deliverables +5. The layer underneath — versioned open JSON mapping onto DEXPI +6. Use-case grid — six cards +7. Your drawings stay yours — local-first by default, cloud opt-in, licensing +8. Final CTA +9. Footer + +Media: the three PNGs from `docs/media/` are copied to `public/media/` and lazy +loaded below the fold. The 7.6 MB GIF and 23.8 MB MP4 stay out of the hosted +bundle; the video is linked to its GitHub Pages copy. + +### Auth — `src/auth/` + +- `firebase.ts` — app init, exports `auth` and `db`. +- `authStore.ts` — zustand slice fed by `onAuthStateChanged`; `user | null`, `ready`. +- `AuthDialog.tsx` — sign in / register tabs, password reset, Google button, built on the existing `Modal`. +- `errors.ts` — Firebase error codes mapped to readable sentences. + +The Firebase web config is committed, not held as a secret. It is public by +design; CI builds with no secrets, and access is controlled by authorized +domains plus security rules. + +### Cloud drawings — `src/cloud/` + +Collection `users/{uid}/drawings/{drawingId}`: + +| Field | Type | Purpose | +|---|---|---| +| `name` | string | list view without downloading the drawing | +| `doc` | string | the whole `ProjectDoc` as JSON | +| `sheetCount` | number | list view detail | +| `sizeBytes` | number | quota feedback | +| `createdAt` / `updatedAt` | timestamp | ordering, conflict detection | + +**Why `doc` is a string, not a map.** Firestore rejects nested arrays, and +`Sheet.underlay.polylines` is `{x,y}[][]` — an array directly containing +arrays. Writing the document as a map throws at runtime as soon as a DXF +underlay is present. Serialising with the existing `serializeDoc` sidesteps +that and `undefined`-valued fields, and makes the size check trivial. + +- `sync.ts` — `saveToCloud`, `loadFromCloud`, `listDrawings`, `renameDrawing`, `deleteDrawing`. Refuses above ~900 KB with a message naming the DXF underlay as the usual cause. +- `CloudDialog.tsx` — "My drawings": open, rename, delete, save as new. +- Cloud autosave is debounced and runs only once a drawing has a `cloudId`. +- Local IndexedDB autosave is unchanged; it remains the offline safety net. +- Conflicts: last write wins, with a warning when the remote `updatedAt` has + moved since load. No merge. + +`firestore.rules`: owner-only read and write, plus a server-side size cap. + +## Consequences + +- All 10 e2e specs navigate to `/`; they move to `/app`. +- README's "no backend, no account, no upload" becomes "local-first by default, cloud optional". +- `firebase.json` gains a `firestore` block. +- Firebase console needs Email/Password and Google providers enabled and a + Firestore database created. + +## Testing + +- Unit (vitest, Firebase mocked): route matching and navigation, cloud + document shape and size guard, round-trip of a doc containing a DXF + underlay, auth error mapping, drawing metadata derivation. +- E2E (Playwright): homepage renders and its CTA reaches the editor; the + editor still works anonymously at `/app`. +- No test performs live network calls. diff --git a/e2e/budget.spec.ts b/e2e/budget.spec.ts index 5e4ecff..566a05e 100644 --- a/e2e/budget.spec.ts +++ b/e2e/budget.spec.ts @@ -5,36 +5,50 @@ declare global { } test('budget: costs accrue while drawing, budget warns when exceeded', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) // empty project: the chip invites setting a budget await expect(page.getByTestId('budget-chip')).toHaveText(/Budget…/) - // place an FT (transmitter, $1500) via palette search + Enter + // place an FT (transmitter, $3800) via palette search + Enter await page.locator('.palette-search').fill('FT') await page.locator('.palette-search').press('Enter') - await expect(page.getByTestId('budget-chip')).toHaveText(/\$1\.5k/) + await expect(page.getByTestId('budget-chip')).toHaveText(/\$3\.8k/) - // place a centrifugal pump ($4500) -> total 6k + // place a centrifugal pump ($9500) -> total 13.3k await page.locator('.palette-search').fill('centrifugal pump') await page.locator('.palette-search').press('Enter') - await expect(page.getByTestId('budget-chip')).toHaveText(/\$6\.0k/) + await expect(page.getByTestId('budget-chip')).toHaveText(/\$13k/) // set a budget below the estimate -> chip goes over-budget red - await page.getByTestId('tb-budget').click() + await page.getByTestId('budget-chip').click() await page.getByTestId('budget-total').fill('5000') - await expect(page.getByTestId('budget-est')).toContainText('6,000') - await expect(page.getByRole('dialog')).toContainText('over budget') + await expect(page.getByTestId('budget-est')).toContainText('13,300') + await expect(page.getByRole('dialog')).toContainText('Over budget') await page.keyboard.press('Escape') await expect(page.getByTestId('budget-chip')).toHaveClass(/budget-over/) - await expect(page.getByTestId('budget-chip')).toHaveText(/\$6\.0k \/ \$5\.0k/) + await expect(page.getByTestId('budget-chip')).toHaveText(/\$13k \/ \$5\.0k/) // exact per-node price: select the pump, set Cost -> totals follow const pumpId = await page.evaluate(() => window.__pid.useStore.getState().doc.sheets[0].nodes.find((n: any) => n.symbolId === 'pump.centrifugal').id) await page.evaluate((id) => window.__pid.useStore.getState().setSelection([id]), pumpId) await page.getByTestId('node-cost').fill('500') - await expect(page.getByTestId('budget-chip')).toHaveText(/\$2\.0k \/ \$5\.0k/) + await expect(page.getByTestId('budget-chip')).toHaveText(/\$4\.3k \/ \$5\.0k/) await expect(page.getByTestId('budget-chip')).not.toHaveClass(/budget-over/) + + // the panel says which price is in force, and ↺ hands it back to the default + await expect(page.locator('.prop-cost-note').first()).toContainText('overriding $9,500') + await page.getByTestId('node-cost-reset').click() + await expect(page.getByTestId('node-cost')).toHaveValue('') + await expect(page.locator('.prop-cost-note').first()).toHaveText('$9,500 budgetary') + await expect(page.getByTestId('budget-chip')).toHaveText(/\$13k \/ \$5\.0k/) + + // a project-wide override from the dialog is labelled as such, not as budgetary + await page.getByTestId('budget-chip').click() + await page.locator('.bd-unit').first().fill('7000') + await page.keyboard.press('Escape') + await expect(page.locator('.prop-cost-note').first()).toContainText('project price') }) diff --git a/e2e/engineering.spec.ts b/e2e/engineering.spec.ts new file mode 100644 index 0000000..fe12c47 --- /dev/null +++ b/e2e/engineering.spec.ts @@ -0,0 +1,94 @@ +import { expect, test, type Page } from '@playwright/test' + +async function store(page: Page) { + await page.waitForFunction(() => '__pid' in window) + await expect(page.locator('.rail')).toBeVisible() +} + +test('an engineering record survives deleting and redrawing its symbol', async ({ page }) => { + await page.goto('/app') + await store(page) + + // place a transmitter and tag it + await page.evaluate(() => { + const { useStore } = (window as never as { __pid: { useStore: { getState(): any } } }).__pid + const s = useStore.getState() + const ft = s.addNode({ symbolId: 'instr.bubble', kind: 'instrument', x: 200, y: 160, rotation: 0 }) + s.setTag(ft, { letters: 'FT', loop: '101' }) + s.setSelection([ft]) + }) + + // fill in the record through the inspector + await page.getByTestId('insp-eng').click() + await page.getByTestId('eng-signal.range').fill('0-150 m3/h') + await page.getByTestId('eng-general.service').fill('Feed water') + await page.locator('.props').click() + + // delete the symbol, then redraw it with the same tag + await page.evaluate(() => { + const { useStore } = (window as never as { __pid: { useStore: { getState(): any } } }).__pid + const s = useStore.getState() + s.deleteIds(s.doc.sheets[0].nodes.map((n: any) => n.id)) + const again = useStore.getState().addNode({ symbolId: 'instr.bubble', kind: 'instrument', x: 260, y: 200, rotation: 0 }) + useStore.getState().setTag(again, { letters: 'FT', loop: '101' }) + useStore.getState().setSelection([again]) + }) + + await page.getByTestId('insp-eng').click() + await expect(page.getByTestId('eng-signal.range')).toHaveValue('0-150 m3/h') + await expect(page.getByTestId('eng-general.service')).toHaveValue('Feed water') +}) + +test('renaming a tag carries its record', async ({ page }) => { + await page.goto('/app') + await store(page) + await page.evaluate(() => { + const { useStore } = (window as never as { __pid: { useStore: { getState(): any } } }).__pid + const s = useStore.getState() + const pt = s.addNode({ symbolId: 'instr.bubble', kind: 'instrument', x: 200, y: 160, rotation: 0 }) + s.setTag(pt, { letters: 'PT', loop: '200' }) + s.setRecordField('PT-200', 'instrument', 'signal.range', '0-10 bar') + s.setSelection([pt]) + }) + + // rename through the tag editor on the Symbol tab + await page.getByTestId('insp-symbol').click() + await page.locator('.tag-loop').fill('201') + await page.locator('.props').click() + + await page.getByTestId('insp-eng').click() + await expect(page.getByTestId('eng-signal.range')).toHaveValue('0-10 bar') +}) + +test('an untagged object is told why it has no record', async ({ page }) => { + await page.goto('/app') + await store(page) + await page.evaluate(() => { + const { useStore } = (window as never as { __pid: { useStore: { getState(): any } } }).__pid + const s = useStore.getState() + const id = s.addNode({ symbolId: 'pump.centrifugal', kind: 'equipment', x: 200, y: 200, rotation: 0 }) + s.setSelection([id]) + }) + await page.getByTestId('insp-eng').click() + await expect(page.locator('.eng-untagged')).toContainText('no tag yet') +}) + +test('a deleted record surfaces as an orphan and can be purged', async ({ page }) => { + await page.goto('/app') + await store(page) + await page.evaluate(() => { + const { useStore } = (window as never as { __pid: { useStore: { getState(): any } } }).__pid + const s = useStore.getState() + const ft = s.addNode({ symbolId: 'instr.bubble', kind: 'instrument', x: 200, y: 160, rotation: 0 }) + s.setTag(ft, { letters: 'FT', loop: '101' }) + s.setRecordField('FT-101', 'instrument', 'signal.range', '0-150') + useStore.getState().deleteIds([ft]) + }) + + await page.getByTestId('rail-checks').click() + const orphan = page.locator('.ws-issue', { hasText: 'engineering record' }) + await expect(orphan).toBeVisible() + + await orphan.getByRole('button', { name: 'Fix' }).click() + await expect(page.locator('.ws-issue', { hasText: 'engineering record' })).toHaveCount(0) +}) diff --git a/e2e/equip.spec.ts b/e2e/equip.spec.ts index 3f550a8..1bb4c14 100644 --- a/e2e/equip.spec.ts +++ b/e2e/equip.spec.ts @@ -4,8 +4,9 @@ import { expect, test } from '@playwright/test' // motor faceplate, and trip it from the events modal. test('compressor: place, tag, start from faceplate, trip from events', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { diff --git a/e2e/fluids.spec.ts b/e2e/fluids.spec.ts index 31f3d3a..3edd790 100644 --- a/e2e/fluids.spec.ts +++ b/e2e/fluids.spec.ts @@ -5,7 +5,8 @@ declare global { } test('fluids are reachable from the toolbar with nothing selected', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) await page.getByTestId('tb-fluids').click() await expect(page.getByRole('dialog', { name: 'Fluids / services' })).toBeVisible() @@ -23,7 +24,8 @@ test('fluids dialog survives a doc with no fluids field (pre-0.9.13 docs, templa const errors: string[] = [] page.on('pageerror', (e) => errors.push(String(e))) page.on('dialog', (d) => void d.accept()) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) await page.locator('select.tb-template').selectOption('sample') // sample doc has no fluids[] await page.waitForSelector('[model-id]') @@ -36,7 +38,8 @@ test('fluids dialog survives a doc with no fluids field (pre-0.9.13 docs, templa }) test('fluid assignment colors the connected run on canvas', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) // build tank -> line -> valve -> line (free end) straight from the store diff --git a/e2e/happy-path.spec.ts b/e2e/happy-path.spec.ts index 47e7ec0..22302ee 100644 --- a/e2e/happy-path.spec.ts +++ b/e2e/happy-path.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from '@playwright/test' test('place, connect, tag, validate, export', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) page.on('dialog', (d) => void d.accept()) // Place a pump, a tank, and an FT instrument through the store dev hook diff --git a/e2e/hmi.spec.ts b/e2e/hmi.spec.ts index dc358c5..2bd6b6e 100644 --- a/e2e/hmi.spec.ts +++ b/e2e/hmi.spec.ts @@ -2,9 +2,10 @@ import { expect, test } from '@playwright/test' test('one click: sample P&ID becomes a running HMI', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.locator('select.tb-template').selectOption('sample') - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() // the doc has no screens yet -> empty-state import button await page.getByTestId('hmi-import-empty').click() const canvas = page.getByTestId('hmi-canvas') @@ -21,8 +22,9 @@ test('one click: sample P&ID becomes a running HMI', async ({ page }) => { test('operate a hand-built screen: start pump, watch it fill, alarm, ack', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { @@ -80,8 +82,9 @@ test('operate a hand-built screen: start pump, watch it fill, alarm, ack', async test('marquee select, duplicate, and navigate a running plant', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() await page.getByText('Tank', { exact: true }).dblclick() await page.getByText('Pump', { exact: true }).dblclick() @@ -114,10 +117,11 @@ test('marquee select, duplicate, and navigate a running plant', async ({ page }) test('bind with the tag picker and pick-on-canvas', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) // the sample plant fills the picker with real P&ID tags await page.locator('select.tb-template').selectOption('sample') - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { @@ -177,8 +181,9 @@ interface PidHook { test('author tools: zoom, cross-screen clipboard, segment editing', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { @@ -252,7 +257,8 @@ test('author tools: zoom, cross-screen clipboard, segment editing', async ({ pag test('scenario injection: trip a pump from the Events menu', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.evaluate(() => { const doc = { schemaVersion: 4, @@ -274,7 +280,7 @@ test('scenario injection: trip a pump from the Events menu', async ({ page }) => const pid = (window as unknown as { __pid: { useStore: { getState(): { loadIntoStore(d: unknown): void } } } }).__pid pid.useStore.getState().loadIntoStore(doc) }) - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() await page.getByTestId('hmi-run-toggle').click() // start the pump, let it ramp await page.evaluate(() => { @@ -307,7 +313,8 @@ test('scenario injection: trip a pump from the Events menu', async ({ page }) => test('multi-sheet import generates a plant overview', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.evaluate(() => { const node = (id: string, symbolId: string, kind: string, x: number, y: number, letters?: string, loop?: string) => ({ id, symbolId, kind, x, y, rotation: 0, ...(letters ? { tag: { letters, loop } } : {}) }) @@ -326,7 +333,7 @@ test('multi-sheet import generates a plant overview', async ({ page }) => { const pid = (window as unknown as { __pid: { useStore: { getState(): { loadIntoStore(d: unknown): void } } } }).__pid pid.useStore.getState().loadIntoStore(doc) }) - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() await page.getByTestId('hmi-import-empty').click() // the dialog offers both sheets (checked) and the overview option await expect(page.getByTestId('pick-sheet')).toHaveCount(2) @@ -346,8 +353,9 @@ test('multi-sheet import generates a plant overview', async ({ page }) => { test('screens: duplicate, home start, delete modal', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() await page.getByText('Tank', { exact: true }).dblclick() // duplicate the active screen: copy becomes active with a unique name @@ -371,8 +379,9 @@ test('screens: duplicate, home start, delete modal', async ({ page }) => { test('alarm summary v2: shelve and out-of-service', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() await page.getByText('Tank', { exact: true }).dblclick() const canvas = page.getByTestId('hmi-canvas') @@ -399,8 +408,9 @@ test('alarm summary v2: shelve and out-of-service', async ({ page }) => { test('multi-pen trend with a time axis', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { @@ -433,8 +443,9 @@ test('multi-pen trend with a time axis', async ({ page }) => { test('build an HMI screen by hand and keep it across reload', async ({ page }) => { page.on('dialog', (d) => void d.accept()) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() // place a tank and a pump via palette double-click await page.getByText('Tank', { exact: true }).dblclick() diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts new file mode 100644 index 0000000..5c0d308 --- /dev/null +++ b/e2e/home.spec.ts @@ -0,0 +1,67 @@ +import { expect, test, type Page } from '@playwright/test' + +/** + * Whether this build has a Firebase project behind it. There is no hardcoded + * config (see src/auth/config.ts), so a fork — and CI, which has no secrets — + * runs with the account layer switched off entirely. The sign-in gate cannot + * be asserted when there is nothing to sign in to. + */ +async function backendConfigured(page: Page): Promise { + await page.waitForFunction(() => '__pid' in window) + return page.evaluate( + () => (window as unknown as { __pid: { firebaseReady?: boolean } }).__pid.firebaseReady === true, + ) +} + +test('the homepage is what / serves, and its CTA opens the editor', async ({ page }) => { + await page.goto('/') + + // the marketing page, not the editor + await expect(page.locator('.home')).toBeVisible() + await expect(page.locator('h1')).toBeVisible() + await expect(page.locator('.toolbar')).toHaveCount(0) + + await page.getByRole('button', { name: /open the editor/i }).first().click() + + await expect(page).toHaveURL(/\/app$/) + await expect(page.locator('.toolbar')).toBeVisible() +}) + +test('sign in on the homepage leads to the editor sign-in screen', async ({ page, context }) => { + await context.addInitScript(() => { try { localStorage.removeItem('pid.dev.skipAuth') } catch {} }) + await page.goto('/') + test.skip(!(await backendConfigured(page)), 'no Firebase project configured — the account layer is off') + await page.getByRole('button', { name: /^sign in$/i }).first().click() + + await expect(page).toHaveURL(/\/app$/) + await expect(page.getByTestId('auth-dialog')).toBeVisible() + await expect(page.getByTestId('auth-email')).toBeVisible() + await expect(page.getByTestId('auth-password')).toBeVisible() +}) + +test('the editor is gated: no account, no canvas', async ({ page, context }) => { + // opt out of the suite-wide dev bypass to see what a real visitor sees + await context.addInitScript(() => { try { localStorage.removeItem('pid.dev.skipAuth') } catch {} }) + await page.goto('/app') + test.skip(!(await backendConfigured(page)), 'no Firebase project configured — the account layer is off') + + await expect(page.getByTestId('auth-dialog')).toBeVisible() + await expect(page.locator('.toolbar')).toHaveCount(0) + await expect(page.locator('.palette')).toHaveCount(0) +}) + +test('a deep link straight to /app boots the editor, not the homepage', async ({ page }) => { + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await expect(page.locator('.home')).toHaveCount(0) + await expect(page.locator('.toolbar')).toBeVisible() +}) + +test('the homepage says the product is actively updated', async ({ page }) => { + await page.goto('/') + const banner = page.locator('.version-banner') + await expect(banner).toBeVisible() + await expect(banner).toContainText(/in active development/i) + await expect(banner).toContainText(/v\d+\.\d+\.\d+/) + await expect(page.getByTestId('version-refresh')).toBeVisible() +}) diff --git a/e2e/interactions.spec.ts b/e2e/interactions.spec.ts index ff08f00..bdfda9f 100644 --- a/e2e/interactions.spec.ts +++ b/e2e/interactions.spec.ts @@ -31,7 +31,8 @@ async function drag(page: Page, from: { x: number; y: number }, to: { x: number; } test('move, connect, ghost-stub guard, quick line editor, panel collapse', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) const ids = await page.evaluate(() => { @@ -115,7 +116,8 @@ test('move, connect, ghost-stub guard, quick line editor, panel collapse', async }) test('typical loop places wired+tagged; advisor flags and fixes the missing I/P', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) // one click drops a complete flow loop, numbered per component type @@ -134,7 +136,7 @@ test('typical loop places wired+tagged; advisor flags and fixes the missing I/P' const lv = s.addNode({ symbolId: 'cv.globe', kind: 'valve', x: 476, y: 320, rotation: 0, config: { actuator: 'diaphragm', fail: 'fc' }, tag: { letters: 'LV', loop: '100' } }) s.addEdge({ lineClass: 'signal.electric', source: { nodeId: lic, portId: 's' }, target: { nodeId: lv, portId: 'sig' } }) }) - await page.locator('.drawer-tabs button', { hasText: 'Advisor' }).click() + await page.locator('.drawer-tabs button', { hasText: 'Issues' }).click() await expect(page.locator('.advisor-fix')).toHaveCount(1) await page.locator('.advisor-fix').click() const converters = await page.evaluate(() => @@ -147,7 +149,8 @@ test('typical loop places wired+tagged; advisor flags and fixes the missing I/P' }) test('polish: grouped undo, label drag, line re-attach, .pnid save name', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) const ids = await page.evaluate(() => { const s = window.__pid.useStore.getState() @@ -223,7 +226,8 @@ test('polish: grouped undo, label drag, line re-attach, .pnid save name', async }) test('stretch, duplicate, live group drag, branch tap into a pipe', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) const ids = await page.evaluate(() => { const s = window.__pid.useStore.getState() @@ -297,7 +301,8 @@ test('stretch, duplicate, live group drag, branch tap into a pipe', async ({ pag }) test('port dots show only on hover or while linking; nodes resize from the panel', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) const tank = await page.evaluate(() => { const s = window.__pid.useStore.getState() @@ -333,6 +338,9 @@ test('port dots show only on hover or while linking; nodes resize from the panel tank, ) expect(scale).toBe(1.25) + // the canvas now opens fitted to the window, so pin it to 1:1 before + // measuring an on-screen box in unscaled pixels + await page.getByTestId('tb-zoom-pct').click() // the async paper applies the resize a frame later — poll, don't snapshot await expect .poll(async () => { diff --git a/e2e/lines.spec.ts b/e2e/lines.spec.ts index 1f7ec78..48cf258 100644 --- a/e2e/lines.spec.ts +++ b/e2e/lines.spec.ts @@ -35,7 +35,8 @@ const undoDepth = (page: Page) => /** Tank + FIC bubble + valve; draws FIC.s -> valve.sig (a straight vertical line at x=344). */ async function setupWithLine(page: Page) { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) await page.evaluate(() => { const s = window.__pid.useStore.getState() @@ -97,7 +98,8 @@ test('vertex drag: one undo step, sticks where dropped, heals straight on the ax }) test('user pins: arm from the panel, click the symbol, draw a line from the new pin', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) const ids = await page.evaluate(() => { const s = window.__pid.useStore.getState() diff --git a/e2e/menus.spec.ts b/e2e/menus.spec.ts new file mode 100644 index 0000000..b4ba78c --- /dev/null +++ b/e2e/menus.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test' + +// Regression: the toolbar scrolls horizontally, and a scroll container clips +// absolutely-positioned descendants — which made the Export and account menus +// open into nothing. Note that toBeVisible() does NOT catch this on its own: a +// clipped element still reports a bounding box. The hit-test below is the part +// that actually fails when the menu is clipped. +test('the Export menu opens where you can actually click it', async ({ page }) => { + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + + await page.getByRole('button', { name: /Export/ }).click() + const pop = page.getByTestId('export-pop') + await expect(pop).toBeVisible() + + const box = await pop.boundingBox() + expect(box).not.toBeNull() + expect(box!.height).toBeGreaterThan(80) + + // the real check: is the menu the thing under its own centre point? + const onTop = await page.evaluate(() => { + const el = document.querySelector('[data-testid="export-pop"]') as HTMLElement + const r = el.getBoundingClientRect() + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + Math.min(24, r.height / 2)) + return el.contains(hit) + }) + expect(onTop, 'the Export menu is clipped by an ancestor').toBe(true) + + await expect(pop.getByRole('menuitem', { name: /SVG image/ })).toBeVisible() + + // and it dismisses again + await page.keyboard.press('Escape') + await expect(pop).toHaveCount(0) +}) + +test('the Export menu survives the toolbar being scrolled', async ({ page }) => { + await page.setViewportSize({ width: 1100, height: 800 }) + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + + await page.waitForSelector('.toolbar') + await page.evaluate(() => { document.querySelector('.toolbar')!.scrollLeft = 200 }) + await page.getByRole('button', { name: /Export/ }).click() + const pop = page.getByTestId('export-pop') + await expect(pop).toBeVisible() + + // stays pinned to its trigger rather than drifting off with the scroll + const aligned = await page.evaluate(() => { + const btn = [...document.querySelectorAll('.export-menu > button')][0] as HTMLElement + const el = document.querySelector('[data-testid="export-pop"]') as HTMLElement + return Math.abs(btn.getBoundingClientRect().right - el.getBoundingClientRect().right) < 2 + }) + expect(aligned, 'the menu drifted away from its button').toBe(true) +}) + +test('the drawing screen carries the version and update note', async ({ page }) => { + await page.goto('/app') + await page.waitForSelector('.status') + const chip = page.getByTestId('version-chip') + await expect(chip).toBeVisible() + await expect(chip).toContainText(/v\d+\.\d+\.\d+/) + await expect(chip).toHaveAttribute('title', /active development/i) +}) + +test('the HMI screen carries it too', async ({ page }) => { + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() + await page.waitForSelector('.hmi-status') + const chip = page.locator('.hmi-status [data-testid="version-chip"]') + await expect(chip).toBeVisible() + await expect(chip).toContainText(/v\d+\.\d+\.\d+/) +}) diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..f9a5129 --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,120 @@ +import { expect, test, type Page } from '@playwright/test' + +/** Seed a small tagged drawing through the dev store hook, the way the other + * specs do — the palette drag is exercised in palette.spec.ts. */ +async function seed(page: Page) { + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.evaluate(() => { + const { useStore } = (window as never as { __pid: { useStore: { getState(): any } } }).__pid + const s = useStore.getState() + const tank = s.addNode({ symbolId: 'vessel.tank', kind: 'equipment', x: 96, y: 96, rotation: 0 }) + const pump = s.addNode({ symbolId: 'pump.centrifugal', kind: 'equipment', x: 280, y: 240, rotation: 0 }) + s.addEdge({ + lineClass: 'process.major', + source: { nodeId: tank, portId: 's' }, + target: { nodeId: pump, portId: 'suction' }, + lineNumber: { size: '6"', spec: 'CS', service: 'CW', seq: '001' }, + }) + const ft = s.addNode({ symbolId: 'instr.bubble', kind: 'instrument', x: 424, y: 96, rotation: 0 }) + s.setTag(ft, { letters: 'FT', loop: '101' }) + }) + // Wait for the shell to be mounted before any test presses a key. A keyboard + // shortcut is a one-shot event: fired before the palette's effect attaches, + // it is lost for good and the assertion then polls a palette that will never + // open. Anything rendered by the shell will do as the settle signal. + await expect(page.locator('.rail')).toBeVisible() +} + +test('the rail moves between workspaces, and the URL and back button follow', async ({ page }) => { + await seed(page) + await expect(page).toHaveURL(/\/app$/) + await expect(page.locator('.canvas-host')).toBeVisible() + + await page.getByTestId('rail-data').click() + await expect(page).toHaveURL(/\/app\/data$/) + await expect(page.locator('.ws-table')).toBeVisible() + await expect(page.locator('.canvas-host')).toHaveCount(0) + + await page.getByTestId('rail-checks').click() + await expect(page).toHaveURL(/\/app\/checks$/) + await expect(page.getByTestId('checks-tally')).toBeVisible() + + // the back button is real navigation, not a re-render + await page.goBack() + await expect(page).toHaveURL(/\/app\/data$/) + await expect(page.locator('.ws-table')).toBeVisible() +}) + +test('Ctrl+1..4 switch workspaces from the keyboard', async ({ page }) => { + await seed(page) + await page.locator('.canvas-host').click({ position: { x: 5, y: 5 } }) + + await page.keyboard.press('ControlOrMeta+3') + await expect(page).toHaveURL(/\/app\/checks$/) + + await page.keyboard.press('ControlOrMeta+1') + await expect(page).toHaveURL(/\/app\/draw$/) + await expect(page.locator('.canvas-host')).toBeVisible() +}) + +test('the deep link straight to a workspace opens it', async ({ page }) => { + await page.goto('/app/checks') + await page.waitForFunction(() => '__pid' in window) + await expect(page.getByTestId('checks-tally')).toBeVisible() +}) + +test('the command palette finds a tag and jumps to it on the drawing', async ({ page }) => { + await seed(page) + await page.getByTestId('rail-data').click() + await expect(page).toHaveURL(/\/app\/data$/) + + // Ctrl+K works from any workspace — the palette lives in the shell + await page.keyboard.press('ControlOrMeta+k') + await expect(page.getByTestId('command-palette')).toBeVisible() + await page.getByTestId('command-input').fill('FT-101') + await page.keyboard.press('Enter') + + // a tag jump always lands you on the drawing, with the object selected + await expect(page).toHaveURL(/\/app\/draw$/) + await expect(page.locator('.status')).toContainText('1 selected') +}) + +test('the palette runs commands, and Ctrl+F still opens it', async ({ page }) => { + await seed(page) + await page.keyboard.press('ControlOrMeta+f') + await expect(page.getByTestId('command-palette')).toBeVisible() + await page.getByTestId('command-input').fill('>checks') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/\/app\/checks$/) +}) + +test('a generated report row locates its object back on the sheet', async ({ page }) => { + await seed(page) + await page.getByTestId('rail-data').click() + + // the instrument index is generated from the drawing, not typed + await expect(page.locator('.ws-table tbody tr')).toHaveCount(1) + await expect(page.locator('.ws-key').first()).toHaveText('FT-101') + + await page.getByTestId('data-tab-lines').click() + await expect(page.locator('.ws-key').first()).toHaveText('6"-CS-CW-001') + + await page.locator('.ws-jump button').first().click() + await expect(page).toHaveURL(/\/app\/draw$/) + await expect(page.locator('.status')).toContainText('1 selected') +}) + +test('the inspector shows where a tagged object is used', async ({ page }) => { + await seed(page) + await page.evaluate(() => { + const { useStore } = (window as never as { __pid: { useStore: { getState(): any } } }).__pid + const s = useStore.getState() + const ft = s.doc.sheets[0].nodes.find((n: any) => n.tag?.letters === 'FT') + s.setSelection([ft.id]) + }) + + await page.getByTestId('insp-used').click() + await expect(page.locator('.used-head', { hasText: 'Sheet' })).toBeVisible() + await expect(page.locator('.used')).toContainText('Sheet 1') +}) diff --git a/e2e/palette.spec.ts b/e2e/palette.spec.ts index 60321c5..eb423db 100644 --- a/e2e/palette.spec.ts +++ b/e2e/palette.spec.ts @@ -5,7 +5,8 @@ declare global { } test('instrument palette: quick row, show-all groups, shortcut + full-name search', async ({ page }) => { - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.waitForFunction(() => Boolean(window.__pid)) const palette = page.locator('.palette') diff --git a/e2e/screenshot.spec.ts b/e2e/screenshot.spec.ts index c20251d..72e3273 100644 --- a/e2e/screenshot.spec.ts +++ b/e2e/screenshot.spec.ts @@ -12,12 +12,13 @@ test('capture a real user document', async ({ page }) => { const text = fs.readFileSync(USER_DOC!, 'utf8') page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.evaluate((t) => { const pid = (window as unknown as { __pid: { useStore: { getState(): { loadIntoStore(d: unknown): void } } } }).__pid pid.useStore.getState().loadIntoStore(JSON.parse(t)) }, text) - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() // build a fresh screen from the sheet with the current importer await page.waitForSelector('[data-testid="hmi-import"], [data-testid="hmi-import-empty"]') const importBtn = page.getByTestId('hmi-import') @@ -36,9 +37,10 @@ test('capture a real user document', async ({ page }) => { test('capture imported sample-plant HMI', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.locator('select.tb-template').selectOption('sample') - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() await page.getByTestId('hmi-import-empty').click() await page.waitForTimeout(400) await page.screenshot({ path: '/tmp/hmi-import-edit.png' }) @@ -57,8 +59,9 @@ test('capture imported sample-plant HMI', async ({ page }) => { test('capture alarm summary and journal', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() await page.getByText('Tank', { exact: true }).dblclick() const canvas = page.getByTestId('hmi-canvas') @@ -80,8 +83,9 @@ test('capture alarm summary and journal', async ({ page }) => { test('capture new widgets on a hand-built screen', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const at = async (wx: number, wy: number) => { @@ -109,8 +113,9 @@ test('capture new widgets on a hand-built screen', async ({ page }) => { test('capture zoomed segment editing', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { @@ -137,7 +142,8 @@ test('capture zoomed segment editing', async ({ page }) => { test('capture manifold flows and process events', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.evaluate(() => { const doc = { schemaVersion: 4, @@ -168,7 +174,7 @@ test('capture manifold flows and process events', async ({ page }) => { const pid = (window as unknown as { __pid: { useStore: { getState(): { loadIntoStore(d: unknown): void } } } }).__pid pid.useStore.getState().loadIntoStore(doc) }) - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() await page.getByTestId('hmi-run-toggle').click() await page.getByTestId('hmi-speed').click() // 5× await page.evaluate(() => { @@ -195,7 +201,8 @@ test('capture manifold flows and process events', async ({ page }) => { test('capture generated plant overview', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.evaluate(() => { const node = (id: string, symbolId: string, kind: string, x: number, y: number, letters?: string, loop?: string) => ({ id, symbolId, kind, x, y, rotation: 0, ...(letters ? { tag: { letters, loop } } : {}) }) @@ -225,7 +232,7 @@ test('capture generated plant overview', async ({ page }) => { const pid = (window as unknown as { __pid: { useStore: { getState(): { loadIntoStore(d: unknown): void } } } }).__pid pid.useStore.getState().loadIntoStore(doc) }) - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() await page.getByTestId('hmi-import-empty').click() await page.getByTestId('import-go').click() await page.getByTestId('hmi-run-toggle').click() @@ -242,8 +249,9 @@ test('capture generated plant overview', async ({ page }) => { test('capture run header, home screen, nav alarm dot', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() // Screen 1: an alarming tank await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') @@ -269,8 +277,9 @@ test('capture run header, home screen, nav alarm dot', async ({ page }) => { test('capture alarm summary v2 with suppression', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { @@ -304,8 +313,9 @@ test('capture alarm summary v2 with suppression', async ({ page }) => { test('capture multi-pen trend and sparkline', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { @@ -346,8 +356,9 @@ test('capture multi-pen trend and sparkline', async ({ page }) => { test('capture faceplate v2 and command journal', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') - await page.getByTestId('open-hmi').click() + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') const world = async (wx: number, wy: number) => { @@ -390,9 +401,10 @@ test('capture faceplate v2 and command journal', async ({ page }) => { test('capture the tag picker and value-source rows', async ({ page }) => { page.on('dialog', (d) => void d.accept()) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.goto('/') + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await page.locator('select.tb-template').selectOption('sample') - await page.getByTestId('open-hmi').click() + await page.getByTestId('rail-hmi').click() await page.getByRole('button', { name: 'New screen' }).click() const canvas = page.getByTestId('hmi-canvas') await page.getByText('Value display', { exact: true }).dblclick() diff --git a/e2e/welcome.spec.ts b/e2e/welcome.spec.ts index 65b47e9..35a98ab 100644 --- a/e2e/welcome.spec.ts +++ b/e2e/welcome.spec.ts @@ -3,8 +3,12 @@ import { expect, test } from '@playwright/test' // opt back out of the suite-wide pre-dismissed state: fresh browser profile test.use({ storageState: { cookies: [], origins: [] } }) -test('first visit: welcome overlay offers the sample plant, then never returns', async ({ page }) => { - await page.goto('/') +test('first visit: welcome overlay offers the sample plant, then never returns', async ({ page, context }) => { + // fresh profile above drops the suite's dev bypass too; put it back, since + // this spec is about the welcome overlay and not about the sign-in gate + await context.addInitScript(() => { try { localStorage.setItem('pid.dev.skipAuth', '1') } catch {} }) + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) await expect(page.getByRole('dialog', { name: /Welcome to IPD Studio/ })).toBeVisible() await expect(page.getByTestId('welcome-video')).toBeVisible() // demo plays inline await expect(page.getByTestId('welcome-blank')).toBeVisible() diff --git a/firebase.json b/firebase.json index 0ea9b34..76e7d82 100644 --- a/firebase.json +++ b/firebase.json @@ -12,6 +12,37 @@ "destination": "/index.html" } ], - "site": "pid-studio-praharsh" + "headers": [ + { + "source": "/@(index.html|sw.js|manifest.webmanifest|registerSW.js)", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, max-age=0, must-revalidate" + } + ] + }, + { + "source": "/assets/**", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] + }, + "firestore": { + "database": "(default)", + "location": "nam5", + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "auth": { + "providers": { + "anonymous": false, + "emailPassword": true + } } -} \ No newline at end of file +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..0e6de7c --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,51 @@ +{ + // Example (Standard Edition): + // + // "indexes": [ + // { + // "collectionGroup": "widgets", + // "queryScope": "COLLECTION", + // "fields": [ + // { "fieldPath": "foo", "arrayConfig": "CONTAINS" }, + // { "fieldPath": "bar", "mode": "DESCENDING" } + // ] + // }, + // + // "fieldOverrides": [ + // { + // "collectionGroup": "widgets", + // "fieldPath": "baz", + // "indexes": [ + // { "order": "ASCENDING", "queryScope": "COLLECTION" } + // ] + // }, + // ] + // ] + // + // Example (Enterprise Edition): + // + // "indexes": [ + // { + // "collectionGroup": "reviews", + // "queryScope": "COLLECTION_GROUP", + // "apiScope": "MONGODB_COMPATIBLE_API", + // "density": "DENSE", + // "multikey": false, + // "fields": [ + // { "fieldPath": "baz", "mode": "ASCENDING" } + // ] + // }, + // { + // "collectionGroup": "items", + // "queryScope": "COLLECTION_GROUP", + // "apiScope": "MONGODB_COMPATIBLE_API", + // "density": "SPARSE_ANY", + // "multikey": true, + // "fields": [ + // { "fieldPath": "baz", "mode": "ASCENDING" } + // ] + // }, + // ] + "indexes": [], + "fieldOverrides": [] +} \ No newline at end of file diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..9828e32 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,19 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + // A signed-in user owns everything under their own uid and nothing else. + match /users/{uid}/drawings/{drawingId} { + allow read, delete: if request.auth != null && request.auth.uid == uid; + allow create, update: if request.auth != null + && request.auth.uid == uid + && request.resource.data.doc is string + && request.resource.data.doc.size() < 900000 + && request.resource.data.name is string + && request.resource.data.name.size() > 0 + && request.resource.data.name.size() <= 200; + } + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/package-lock.json b/package-lock.json index faeb118..5adc3ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "pid-studio", - "version": "0.10.0", + "name": "ipd-studio", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pid-studio", - "version": "0.10.0", - "license": "AGPL-3.0-only", + "name": "ipd-studio", + "version": "0.13.0", + "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@joint/core": "^4.3.1", "dxf-parser": "^1.1.2", "fast-xml-parser": "^5.11.0", + "firebase": "^12.18.0", "idb-keyval": "^6.3.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -1804,6 +1805,671 @@ } } }, + "node_modules/@firebase/ai": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.15.0.tgz", + "integrity": "sha512-Aj7TbFdAIWZdkX8JfdDStERpR35g6WNs+7XhNPtFLFOizUotj6k4N/D8HJkR45165HhnMJBe4hjOeeaxnnn56Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.24", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.24.tgz", + "integrity": "sha512-OfIAcIIwoqXjBzS+DnUQpOZ7i3ePaNFg83KPpDWjBZUKJmqMwzzAtLJqmKZOkQnW8im6vFR6f2I3M/9hxVd+QA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.30", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.30.tgz", + "integrity": "sha512-uVZEKlLaW4AHAhv8zoN9cTmveX6v86AVqcJ4LCaRCMTChTH8/NsjbisTq1lpBfWCLWS1spqwSHB4vol/YSCdMA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.24", + "@firebase/analytics-types": "0.8.5", + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.5.tgz", + "integrity": "sha512-kdnooE7Bis2jEnsqcerRwn/UQpH5D3uvHpku7OdUM9TJN3omlu6iYtbyAQ6XkAJRgJtO0aNf9OOkW8J6D59oCA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.16.1.tgz", + "integrity": "sha512-tjUEorFyKrurH7PbLWv9zDHRuU4mLefgD/yY38D584ziorIRlQz/PVjx4c/SCGyCuUlmyzt72mK+Lh+COAywNQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.13.1.tgz", + "integrity": "sha512-l8y3dmnhodXks/APAwx4tWqRl3tk8b9874KF1FJyKg1DIU+kMD0l52iz7S9/MW+eK8k6cjJg0G0iJ5KQAsQpow==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.7.tgz", + "integrity": "sha512-fBb/xSMyIKv1nDmccfzz3IAGBzx/cQOxmZai66rJzifb1hMMkztf824Xx7LhpTUe7HNUbXwY90IvJ66fi8q6yg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.13.1", + "@firebase/app-check-types": "0.5.5", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.5.tgz", + "integrity": "sha512-qId34pVZ2CXTmtu4ofW5leiI93DvnOvIcr/5GT7MZPw5WXAi6nZoU+g9cNRgl7K90UJSbK0cXxten4meYMPyZg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.5.tgz", + "integrity": "sha512-+DF4gzFrlwGFyky38o4T/YN/r51l70yCtJ2HTkwmRj8FCMwPjm4hLH4fQbj6BUvzkiFqliBkitFsRpf1/YZkWA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.17.tgz", + "integrity": "sha512-5GdJWobqs6jbNYOnaQiSa4Ng8gnFerhqr7nY+v5jlnT7o9QbEeIZRLPQpnLe0TxYSWfRV2lAMbyR0kbXeIos9Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.16.1", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.6.tgz", + "integrity": "sha512-yPLahy7Esfu2w/yme3msVK4xTkDXQqq6szfQn8yVOQpCKiT5GVFjqNpLbuz6NkX0WuTwUidkmPewW3r4xkvpeg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.2" + } + }, + "node_modules/@firebase/auth": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.5.tgz", + "integrity": "sha512-1AXoBJqBVD8WL8FZYo3S2GmJF9YUoom6Y6ngMxOSkzzhW5sT83pLchb6TGFgxes91dfXx8s/VYc5VrLDNqpLog==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.10.tgz", + "integrity": "sha512-Bvklg2nL7BrBFCAqdsleW8fse9Jh0fARqJPlJmA40uermfWx1bJiO7dnKyZN3J39d2TFVd4VJXtg3i6Wg92/YQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.5", + "@firebase/auth-types": "0.13.2", + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.6.tgz", + "integrity": "sha512-FgwZqDrBqgK0BHI70QTv1v/5wmuDF9f8fsJFvKSxoRlK2PPfSQtZAk2jhXu4pe9BZzFi/e4UYusU/bEQHdf6Qg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.2.tgz", + "integrity": "sha512-OU+miuoSxIWYN7GT291d2ylVJBL3k/OePo7/JDSoQtkFQoEiGBc4AHuMjj/seqFIsHrqnjrAfRCk4pfufoJolQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.5.tgz", + "integrity": "sha512-vuFDcL91Q+2ZuBJkyOh86T4q0B4ffNTDjc/A38tybO56odQABxRTFLTIowCWqAKeIcgo37GowWMVgFF73gD8Qw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.7.4.tgz", + "integrity": "sha512-su1aGWlzhxb+xtggCUSsufJn1FDa06SBDK71y+fpQ+g2zMhMExik6FUv/odkKzOF/xfWVjabCbWBcjJY3MceDA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.5.tgz", + "integrity": "sha512-/JGpvszLoNXNgzilRXocigGfFF4hbcPA9wN1i1kjJx6oKkXgkHteZYl3lQs1lJX3ETDf6bv7zI15lPMVUp4sAQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.7.tgz", + "integrity": "sha512-lBq9sJm8MnJINKJnkAKSOj2MbC66xGoSVCcGkPtstl+lkoKEBtD46qHLbuDgW/WbLPoKVm17RIN8BxHj+Z2F2w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/database": "1.1.5", + "@firebase/database-types": "1.0.22", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + }, + "peerDependenciesMeta": { + "@firebase/app": { + "optional": true + }, + "@firebase/app-compat": { + "optional": true + } + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.22.tgz", + "integrity": "sha512-YAZNXsjY9EQQ+pKw/3ax8n5FgolHC7Qew7EY5RceYdl0R2ZP+kCv1O0DkKlcceue8uQCOreHCNN7PWVFpY1Nug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.6", + "@firebase/util": "1.15.3" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.17.1.tgz", + "integrity": "sha512-8lqPNf2w10CtYG+tayVjZO1pSyQpnhztQRudeD109VtXDzNbASTaYdO43sj5PMsDcWq0aOYY3RmJOUlXu9++jw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "@firebase/webchannel-wrapper": "1.0.7", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "re2js": "^2.8.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.13.tgz", + "integrity": "sha512-l9dCewxMzzLOIhcwTjERCKxrWOn1kZ9JvwOQq9zZNq4I/Nbvb/B9njT230V61uvQ9qZ3/qpUG758D8DDTEFXnw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/firestore": "4.17.1", + "@firebase/firestore-types": "3.0.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.5.tgz", + "integrity": "sha512-dbdMAQkMd5dwWc48eupz/Y6/E9ruat3+gY5lhVKscvvT/HnDBEEMzJW38zdKhgdnglZHGk2vUsJMOHAphMHGMA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.14.0.tgz", + "integrity": "sha512-DhuYFr0eMhp+s/PNEk6SiMsYkc00+XVOUeKbrl8MOzQNZI3SKQpqoDR1d+keNDAutwmHRWTUAdXBoVPD+xxVUw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/messaging-interop-types": "0.2.6", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.5.0.tgz", + "integrity": "sha512-T3BDIToESZUHt7438wyKYMkRjKg3m1xAIux5fVpNvTRQlDOFLukWniQWBDhJ2znpU9kxMTR6+e31TVo8P15PZw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/functions": "0.14.0", + "@firebase/functions-types": "0.6.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.5.tgz", + "integrity": "sha512-Zc0pURjthHXzSj54ZivCkzKDSV1r/wIpnmHdhq82q2yFFPVoncG/ZJjnVMiANvQfeww/QnElhzODpfwUucVwdA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.24", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.24.tgz", + "integrity": "sha512-Ui52ey8wHoWqkBbXRKJEKYWylI0JZogZmoLS+o8Anh1bxWtK27ZYjLla1UJAAdC5DCKLJ2qAp0VpJOjg8VOX1g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.24.tgz", + "integrity": "sha512-8M5nlcWwYt881x83COP2odq5vgf+NgwJh+RMd4LRSv8JI1pxwDfgrDJOERrjTgfC9J5Z0vnQX4b1pdN914e0Zw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/installations-types": "0.5.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.5.tgz", + "integrity": "sha512-e9UYcju3puDl1vdrcKIi5dExzHLameOT/Tc61Q48PYwxtsM1NzZh/ikGbdBQTsbRgg0EMZqdPr0/m5ODUBobrg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.2.tgz", + "integrity": "sha512-J2VO4NFTc0xQFrxV1B/lm5balicm9cwuX2acR9Yn41fN8KgUeQFo+VJV222IqW2FPSXKDu9uo5WdQFWf9TPbYg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.13.2.tgz", + "integrity": "sha512-KcZoqUu2ih4sLH91dW9tmyHjCR0IQyNzSLZunX5uq7cLeImXb4uO2I0wq5FACduO2I+FoTeWa2BtUv7Jpowqdw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/messaging-interop-types": "0.2.6", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.29.tgz", + "integrity": "sha512-8Twe4CeYvAx8AzjBxyyQFKzinaMGGt13hPQBqaARQ2QZjagrEaqSVBe+Zy6F4L/vT8DV168yRgRu7W/RK7p+4w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/messaging": "0.13.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.6.tgz", + "integrity": "sha512-MVzvkKe2V4H2dHu5oOxRfeKQcfTwWmCgnzsC4V1q3ixun5iiL8riCZ2qI35rDhCL4glPGiu0jHxDbpVNuTKfow==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.14.tgz", + "integrity": "sha512-9PH1XEZVHErxGdbXluvGz4Uyjw4W955H8v7Mv9rHIybRrg5F2Ac2tvf5+mSf5EtnxWNmxrD2WLnvMFaZP4sMrQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.27.tgz", + "integrity": "sha512-O/ozTf/EbChN94Pk7bd1eUC0PAC36726AwsaiJyC0bZzSBWfLGSWASGPH5pebUWXQPJtGxIJYRkkbX5l0Qa+Hw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/performance": "0.7.14", + "@firebase/performance-types": "0.2.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.5.tgz", + "integrity": "sha512-PRzOgB+/M+6AKlEkY8a9xy5Ff5SfZPIh4iShXhn2WAcL/euSbK7bdLqWysHduunNJhGFsXa22Ag3ixqL6rQtYg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.9.2.tgz", + "integrity": "sha512-Rii93DkXjM+RE/ytHdYa7EIiQLJbdBr+W8EEiqd/vbLCOC+1FdkDuRiumJBp6t3yewHGbdqbpjB3fk3z0cZ+8g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.29.tgz", + "integrity": "sha512-mY7JtTISK6F4g4T0x3kVepr5cp0NXL7f5yjIUXXGaTrg4qUlFBWRbENaHaqZ78dwmNcLm24h/yPsOVRlDXEXhA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/remote-config": "0.9.2", + "@firebase/remote-config-types": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.2.tgz", + "integrity": "sha512-i8k1omVfoAnaT1ZPv2FFjxMZrATYsO/GnFgHEj5+7fAJLzi8wLnGGv1WK06oEAD6ltrWXSO1HzeNyajn/NjMWw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.5.tgz", + "integrity": "sha512-r2tozN/BlEewLi70tJNUQPzWwbex9GM7NgXZsamHKQrjKEfcR0i4jGgHBKAKA4hbwiPE9eNMb3hcxWnDpeJZwg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.5.tgz", + "integrity": "sha512-vO0tFPxXbKDKdlTu8tYT08S9t9ezUTJYEdLCULpWxWq2aq6zojwN1QmAB+1a50AI/g47GlWUJn1XPncm/PDZsw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/storage": "0.14.5", + "@firebase/storage-types": "0.8.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.5.tgz", + "integrity": "sha512-GEDs5P+rNUfNcS+wxIdOLAHficife2YXtvTJnyi3ssrX11AAtBg+nDUdbYh8vuBzWehVQZPmztGUUnud4L+4yg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.3.tgz", + "integrity": "sha512-c/z/gaIlaaLZEuGbE6sLUuJ61tskg1JghvhcNQzW948ASBinbVBBRnZTC4b4yt4LaEtJQkYlyzqLHcutFwEIvA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.7.tgz", + "integrity": "sha512-phBFwieDLvkZGYN9CE9ZFNEIoBVksprzsnCzQejCmCHtgwCXReeuRpoEGN9C4EbhONztv8NRV1tau6Rb9pONwQ==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -1936,6 +2602,63 @@ "node": ">=20" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", @@ -2723,6 +3446,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -3270,6 +4002,30 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anynum": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", @@ -3584,6 +4340,38 @@ "node": ">=18" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -3882,6 +4670,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -4064,7 +4858,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4183,6 +4976,18 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4241,6 +5046,42 @@ "node": ">=10" } }, + "node_modules/firebase": { + "version": "12.18.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.18.0.tgz", + "integrity": "sha512-XaL6tlE5Xd20ZDhckqOMIw+JJTET+wTdeZPxQ7ihc42oxRb7kWUyn/j1LO5V9dH1xq8Rv5R71Pv1fBCdIkt9Rw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.15.0", + "@firebase/analytics": "0.10.24", + "@firebase/analytics-compat": "0.2.30", + "@firebase/app": "0.16.1", + "@firebase/app-check": "0.13.1", + "@firebase/app-check-compat": "0.4.7", + "@firebase/app-compat": "0.5.17", + "@firebase/app-types": "0.9.6", + "@firebase/auth": "1.13.5", + "@firebase/auth-compat": "0.6.10", + "@firebase/data-connect": "0.7.4", + "@firebase/database": "1.1.5", + "@firebase/database-compat": "2.1.7", + "@firebase/firestore": "4.17.1", + "@firebase/firestore-compat": "0.4.13", + "@firebase/functions": "0.14.0", + "@firebase/functions-compat": "0.5.0", + "@firebase/installations": "0.6.24", + "@firebase/installations-compat": "0.2.24", + "@firebase/messaging": "0.13.2", + "@firebase/messaging-compat": "0.2.29", + "@firebase/performance": "0.7.14", + "@firebase/performance-compat": "0.2.27", + "@firebase/remote-config": "0.9.2", + "@firebase/remote-config-compat": "0.2.29", + "@firebase/storage": "0.14.5", + "@firebase/storage-compat": "0.4.5", + "@firebase/util": "1.15.3" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4369,6 +5210,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4592,11 +5442,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "dev": true, "license": "ISC" }, "node_modules/idb-keyval": { @@ -4787,6 +5642,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5477,6 +6341,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -5497,6 +6367,12 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -5868,6 +6744,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5878,6 +6777,15 @@ "node": ">=6" } }, + "node_modules/re2js": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", + "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -6001,6 +6909,15 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6133,6 +7050,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6454,6 +7391,20 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -6557,6 +7508,18 @@ "node": ">=4" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -6740,6 +7703,12 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -6904,6 +7873,12 @@ "node": ">=22.19.0" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -7225,6 +8200,12 @@ "node": ">=18" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -7235,6 +8216,29 @@ "node": ">=20" } }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -7602,6 +8606,23 @@ "workbox-core": "7.4.1" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -7634,6 +8655,15 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7641,6 +8671,33 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zundo": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz", diff --git a/package.json b/package.json index 927d453..02f21ad 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "ipd-studio", "private": true, - "version": "0.10.0", - "license": "AGPL-3.0-only", + "version": "0.15.0", + "license": "PolyForm-Noncommercial-1.0.0", "type": "module", "scripts": { "dev": "vite", @@ -15,6 +15,7 @@ "@joint/core": "^4.3.1", "dxf-parser": "^1.1.2", "fast-xml-parser": "^5.11.0", + "firebase": "^12.18.0", "idb-keyval": "^6.3.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/playwright.config.ts b/playwright.config.ts index 13409b0..c1a9eed 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,7 +12,14 @@ export default defineConfig({ origins: [ { origin: 'http://localhost:5173', - localStorage: [{ name: 'pid.ui.welcomed', value: '1' }], + localStorage: [ + { name: 'pid.ui.welcomed', value: '1' }, + // The editor requires an account. Firebase keeps its session in + // IndexedDB, which storageState cannot carry, so specs that are + // testing the editor use the dev-only bypass instead of driving + // the sign-in form 40 times. See devBypass() in EditorRoot. + { name: 'pid.dev.skipAuth', value: '1' }, + ], }, ], }, diff --git a/public/media/editor.png b/public/media/editor.png new file mode 100644 index 0000000..87427bc Binary files /dev/null and b/public/media/editor.png differ diff --git a/public/media/fluids.png b/public/media/fluids.png new file mode 100644 index 0000000..6f0743e Binary files /dev/null and b/public/media/fluids.png differ diff --git a/public/media/hmi.png b/public/media/hmi.png new file mode 100644 index 0000000..f4358c1 Binary files /dev/null and b/public/media/hmi.png differ diff --git a/src/App.tsx b/src/App.tsx index 1d4698b..e003dc0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,8 @@ -import { lazy, Suspense, useState } from 'react' +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { useState } from 'react' import Canvas from './canvas/Canvas' import Toolbar from './panels/Toolbar' import Palette from './panels/Palette' @@ -6,13 +10,9 @@ import PropertyPanel from './panels/PropertyPanel' import Drawer from './panels/Drawer' import SheetTabs from './panels/SheetTabs' import StatusBar from './panels/StatusBar' -import SearchOverlay from './panels/SearchOverlay' import QuickLineEditor from './panels/QuickLineEditor' -import UpdateToast from './panels/UpdateToast' import WelcomeOverlay from './panels/WelcomeOverlay' -const HmiWorkspace = lazy(() => import('./hmi/HmiWorkspace')) - function readPref(key: string, fallback: boolean): boolean { try { const v = localStorage.getItem(key) @@ -30,31 +30,18 @@ function writePref(key: string, value: boolean): void { } } +/** The Draw workspace: the drawing sheet and everything that serves it. + * Workspace switching, the command palette and the update toast belong to the + * shell (EditorRoot) so they survive moving between workspaces. */ export default function App() { const [showPalette, setShowPalette] = useState(() => readPref('pid.ui.palette', true)) const [showProps, setShowProps] = useState(() => readPref('pid.ui.props', true)) const togglePalette = (v: boolean) => { setShowPalette(v); writePref('pid.ui.palette', v) } const toggleProps = (v: boolean) => { setShowProps(v); writePref('pid.ui.props', v) } - const [workspace, setWorkspaceState] = useState<'pid' | 'hmi'>(() => { - try { return localStorage.getItem('pid.ui.workspace') === 'hmi' ? 'hmi' : 'pid' } catch { return 'pid' } - }) - const setWorkspace = (w: 'pid' | 'hmi') => { - setWorkspaceState(w) - try { localStorage.setItem('pid.ui.workspace', w) } catch { /* private mode */ } - } - - if (workspace === 'hmi') { - return ( - Loading HMI workspace…
}> - setWorkspace('pid')} /> - - - ) - } return (
- setWorkspace('hmi')} /> + {showPalette ? ( togglePalette(false)} /> ) : ( @@ -75,9 +62,7 @@ export default function App() { )} - -
) diff --git a/src/EditorRoot.tsx b/src/EditorRoot.tsx new file mode 100644 index 0000000..7ce9776 --- /dev/null +++ b/src/EditorRoot.tsx @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { lazy, Suspense, useEffect } from 'react' +import App from './App' +import AuthScreen from './auth/AuthScreen' +import { useStore } from './store/store' +import { useAuthStore } from './auth/authStore' +import { firebaseReady } from './auth/config' +import { ensureAuth } from './auth/session' +import { startAutosave, restoreAutosave } from './persist/autosave' +import { startCloudAutosave } from './cloud/autosave' +import WorkspaceRail from './WorkspaceRail' +import CommandPalette from './panels/CommandPalette' +import UpdateToast from './panels/UpdateToast' +import { navigateWorkspace, useWorkspace } from './routes' + +// Split at the workspace boundary, the way the homepage is already split from +// the editor: Draw must not pay for the HMI simulator or the report tables. +const DataWorkspace = lazy(() => import('./workspaces/DataWorkspace')) +const ChecksWorkspace = lazy(() => import('./workspaces/ChecksWorkspace')) +const HmiWorkspace = lazy(() => import('./hmi/HmiWorkspace')) + +/** + * The editor shell. The rail and the command palette live out here so they + * persist across workspaces — switching screens must never cost you Ctrl+K. + */ +function Workspaces() { + const workspace = useWorkspace() + return ( +
+ +
+ {workspace === 'draw' ? ( + + ) : ( + Loading {workspace}…
}> + {workspace === 'data' && } + {workspace === 'checks' && } + {workspace === 'hmi' && navigateWorkspace('draw')} />} + + )} +
+ + +
+ ) +} + +/** Editor-only bootstrapping. This used to run at module scope in main.tsx, + * which meant a visitor reading the homepage paid for autosave restore and a + * file-handler registration they will never use. It now runs only once + * someone is actually through the door. */ +let booted = false + +function bootEditor(): void { + if (booted) return + booted = true + + startAutosave() + startCloudAutosave() + void restoreAutosave().then((saved) => { + if (saved && window.confirm(`Restore autosaved drawing “${saved.meta.name}”?`)) { + useStore.getState().loadIntoStore(saved) + } + }) + + // Installed-PWA file handling: double-clicked .pnid files arrive here. + interface LaunchQueueWindow extends Window { + launchQueue?: { setConsumer(cb: (params: { files: { getFile(): Promise }[] }) => void): void } + } + ;(window as LaunchQueueWindow).launchQueue?.setConsumer((params) => { + void (async () => { + const handle = params.files?.[0] + if (!handle) return + const file = await handle.getFile() + const { loadAnyText } = await import('./persist/file') + loadAnyText(file.name, await file.text()) + })() + }) +} + +/** + * Test seam, development builds only. Firebase keeps its session in IndexedDB, + * which Playwright's storageState cannot carry between contexts, so without + * this every editor spec would have to drive the sign-in form first. The branch + * is compiled out of production — `import.meta.env.DEV` is substituted at build + * time — so it can never open the real app. + */ +function devBypass(): boolean { + if (!import.meta.env.DEV) return false + try { + return localStorage.getItem('pid.dev.skipAuth') === '1' + } catch { + return false + } +} + +export default function EditorRoot() { + const user = useAuthStore((s) => s.user) + const ready = useAuthStore((s) => s.ready) + // With no Firebase project configured — a fork, or a local build with no + // .env.local — there is no account system to gate on, so the editor opens + // straight into fully local mode. Gating on an account that can never exist + // would leave a fork with a permanently locked door. + const allowed = !firebaseReady || Boolean(user) || devBypass() + + useEffect(() => { + if (firebaseReady) ensureAuth() + }, []) + + useEffect(() => { + if (allowed) bootEditor() + }, [allowed]) + + if (firebaseReady && !ready && !devBypass()) { + return
Checking your account…
+ } + if (!allowed) return + return +} diff --git a/src/WorkspaceRail.tsx b/src/WorkspaceRail.tsx new file mode 100644 index 0000000..41a28bb --- /dev/null +++ b/src/WorkspaceRail.tsx @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { useEffect } from 'react' +import { WORKSPACES, navigateWorkspace, type Workspace } from './routes' +import { useStore } from './store/store' +import { issuesFor } from './validate/issues' + +interface Entry { + icon: string + label: string + hint: string +} + +/** + * The editor's top-level navigation. It exists because the toolbar was full: + * ~18 controls already scrolling horizontally, with nowhere to hang a second + * screen. Workspaces go here instead, so the toolbar can stay about drawing. + */ +const ENTRIES: Record = { + draw: { icon: '✎', label: 'Draw', hint: 'The P&ID sheet' }, + data: { icon: '▦', label: 'Data', hint: 'Instrument index and line list, generated from the drawing' }, + checks: { icon: '✓', label: 'Checks', hint: 'Every validation finding and suggestion, full screen' }, + hmi: { icon: '⊞', label: 'HMI', hint: 'HMI Studio — operator screens and simulation' }, +} + +export default function WorkspaceRail({ active }: { active: Workspace }) { + // Only actionable counts earn a badge. Suggestions are advice and would cry + // wolf on a drawing that is merely unfinished, so the badge counts hard + // findings alone — the things that would stop a drawing being issued. + const findings = issuesFor(useStore((s) => s.doc)).findings.length + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return + const n = Number(e.key) + if (!Number.isInteger(n) || n < 1 || n > WORKSPACES.length) return + e.preventDefault() + navigateWorkspace(WORKSPACES[n - 1]!) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + return ( + + ) +} diff --git a/src/app.css b/src/app.css index 2d6135b..79d32eb 100644 --- a/src/app.css +++ b/src/app.css @@ -1,6 +1,31 @@ +/* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; + * commercial use requires a paid license (see COMMERCIAL-LICENSE.md). */ + * { box-sizing: border-box; margin: 0; } html, body, #root { height: 100%; } -body { font: 13px/1.4 system-ui, sans-serif; color: #1a1a1a; } + +:root { + /* one neutral ramp, cool-biased to sit under the drafting-ink UI */ + --c-desk: #d3d7de; + --c-chrome: #f4f5f7; + --c-chrome-2: #eceef1; + --c-panel: #fafbfc; + --c-surface: #ffffff; + --c-line: #d7dae0; + --c-line-soft: #e5e8ec; + --c-ink: #1b2430; + --c-ink-2: #5a6673; + --c-ink-3: #8a94a0; + --c-accent: #0e6e7a; + --c-accent-soft: #e6f1f3; + --c-warn: #9a6a15; + --c-bad: #c53030; + --r: 5px; + --tb-h: 38px; +} + +body { font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--c-ink); background: var(--c-chrome); } .app { height: 100%; @@ -9,37 +34,71 @@ body { font: 13px/1.4 system-ui, sans-serif; color: #1a1a1a; } "toolbar toolbar toolbar" "palette center props" "status status status"; - grid-template-rows: auto 1fr 24px; - --palette-w: 232px; - --props-w: 280px; - grid-template-columns: var(--palette-w) 1fr var(--props-w); + grid-template-rows: auto minmax(0, 1fr) 26px; + --palette-w: 236px; + --props-w: 288px; + grid-template-columns: var(--palette-w) minmax(0, 1fr) var(--props-w); } -.app.no-palette { --palette-w: 24px; } -.app.no-props { --props-w: 24px; } +.app.no-palette { --palette-w: 26px; } +.app.no-props { --props-w: 26px; } .panel-strip { border: none; - background: #f0f0f4; + background: var(--c-chrome-2); cursor: pointer; - font-size: 11px; - color: #556; + font-size: 10.5px; + letter-spacing: .04em; + color: var(--c-ink-2); writing-mode: vertical-rl; - padding: 12px 4px; -} -.panel-strip:hover { background: #e4e8f0; color: #2b6cb0; } -.strip-left { grid-area: palette; border-right: 1px solid #d5d5d5; } -.strip-right { grid-area: props; border-left: 1px solid #d5d5d5; } -.panel-collapse { border: none; background: none; cursor: pointer; color: #99a; font-size: 14px; padding: 2px 6px; } -.panel-collapse:hover { color: #2b6cb0; } -.palette-head { display: flex; align-items: center; } -.palette-head .palette-search { flex: 1; margin-right: 2px; } -.props-head { display: flex; justify-content: flex-end; margin: -6px -6px 0 0; } + padding: 14px 4px; +} +.panel-strip:hover { background: var(--c-accent-soft); color: var(--c-accent); } +.strip-left { grid-area: palette; border-right: 1px solid var(--c-line); } +.strip-right { grid-area: props; border-left: 1px solid var(--c-line); } +.panel-collapse { border: none; background: none; cursor: pointer; color: var(--c-ink-3); font-size: 14px; line-height: 1; padding: 3px 6px; border-radius: 4px; } +.panel-collapse:hover { color: var(--c-accent); background: var(--c-accent-soft); } + +/* panel headers: a shared bar so both sides line up with the toolbar below it */ +.panel-head { + display: flex; align-items: center; gap: 6px; + height: 30px; padding: 0 6px 0 10px; + border-bottom: 1px solid var(--c-line-soft); + background: var(--c-chrome); + position: sticky; top: 0; z-index: 3; +} +.panel-head h2 { + margin: 0; font-size: 10px; font-weight: 700; letter-spacing: .1em; + text-transform: uppercase; color: var(--c-ink-3); +} +.panel-head .sp { flex: 1; } +.palette-head { display: flex; align-items: center; gap: 4px; padding: 6px 6px 4px; } +.palette-head .palette-search { flex: 1; margin: 0; width: auto; } .center { grid-area: center; display: flex; flex-direction: column; min-width: 0; min-height: 0; } -.toolbar { grid-area: toolbar; display: flex; align-items: center; gap: 6px; row-gap: 4px; flex-wrap: wrap; padding: 4px 12px; border-bottom: 1px solid #d5d5d5; background: #f7f7f7; min-height: 40px; } -.palette { grid-area: palette; overflow-y: auto; border-right: 1px solid #d5d5d5; background: #fafafa; } -.canvas-host { flex: 1; overflow: hidden; position: relative; background: #e8e8ec; } -.props { grid-area: props; overflow-y: auto; border-left: 1px solid #d5d5d5; background: #fafafa; padding: 10px; } -.status { grid-area: status; display: flex; align-items: center; gap: 16px; padding: 0 12px; border-top: 1px solid #d5d5d5; background: #f7f7f7; font-size: 12px; color: #555; } +.toolbar { + grid-area: toolbar; display: flex; align-items: center; gap: 4px; + /* One row, always. Wrapping pushed the account control onto a second line + and shoved the canvas down; a narrow window scrolls the row instead. */ + flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; + scrollbar-width: thin; + padding: 4px 10px; min-height: var(--tb-h); + border-bottom: 1px solid var(--c-line); background: var(--c-chrome); +} +.app-name { font-size: 13px; font-weight: 700; letter-spacing: -.01em; color: var(--c-ink); } +.doc-name { font-size: 12px; color: var(--c-ink-2); max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.palette { grid-area: palette; overflow-y: auto; overflow-x: hidden; border-right: 1px solid var(--c-line); background: var(--c-panel); } +/* the desk the drawing sheet sits on; the sheet itself is drawn in the SVG */ +.canvas-host { flex: 1 1 0; min-height: 0; overflow: hidden; position: relative; background: var(--c-desk); } +.canvas-host svg { display: block; } +.props { grid-area: props; overflow-y: auto; overflow-x: hidden; border-left: 1px solid var(--c-line); background: var(--c-panel); } +.props-body { padding: 8px 10px 14px; } +.status { + grid-area: status; display: flex; align-items: center; gap: 14px; + padding: 0 12px; border-top: 1px solid var(--c-line); + background: var(--c-chrome); font-size: 11.5px; color: var(--c-ink-2); +} +.status .sp { flex: 1; } +.status-dot { color: var(--c-ink-3); } +.status-dot.on { color: var(--c-warn); } .palette-search { width: calc(100% - 16px); margin: 8px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font: inherit; } .palette-cat { display: block; width: 100%; text-align: left; background: none; border: none; padding: 6px 10px; font: inherit; font-weight: 600; cursor: pointer; color: #333; } @@ -48,8 +107,6 @@ body { font: 13px/1.4 system-ui, sans-serif; color: #1a1a1a; } .palette-entry:hover { border-color: #b5b5c5; background: #fff; } .palette-preview { width: 36px; height: 32px; } .palette-label { font-size: 9.5px; color: #555; text-align: center; line-height: 1.1; max-width: 64px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.tb-coffee { align-self: center; margin-right: 6px; padding: 4px 10px; border-radius: 999px; background: linear-gradient(135deg, #f7b733, #ec4899); color: #fff; font-size: 11.5px; font-weight: 700; text-decoration: none; white-space: nowrap; box-shadow: 0 1px 4px rgba(236, 72, 153, 0.35); transition: transform 0.12s, box-shadow 0.12s; } -.tb-coffee:hover { transform: translateY(-1px); box-shadow: 0 3px 9px rgba(236, 72, 153, 0.5); color: #fff; } .update-toast { position: fixed; right: 16px; bottom: 40px; z-index: 60; display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 10px; background: #1b2233; color: #eef2fa; font-size: 13px; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); } .update-toast button { padding: 6px 14px; border: none; border-radius: 6px; background: #4da3ff; color: #08101f; font-weight: 700; cursor: pointer; } .update-toast button:hover { background: #6db4ff; } @@ -79,10 +136,11 @@ body { font: 13px/1.4 system-ui, sans-serif; color: #1a1a1a; } .tag-error { font-size: 11.5px; color: #c53030; margin: 2px 0 4px; } button { font: inherit; } -.drawer { border-top: 1px solid #d5d5d5; background: #fafafa; max-height: 180px; display: flex; flex-direction: column; } -.drawer-tabs { display: flex; gap: 2px; padding: 4px 8px 0; } -.drawer-tabs button { border: 1px solid #d5d5d5; border-bottom: none; background: #efefef; padding: 3px 12px; border-radius: 6px 6px 0 0; cursor: pointer; font-size: 12px; } -.drawer-tabs button.active { background: #fff; font-weight: 600; } +.drawer { border-top: 1px solid var(--c-line); background: var(--c-panel); max-height: 190px; display: flex; flex-direction: column; flex: none; } +.drawer-tabs { display: flex; align-items: flex-end; gap: 2px; padding: 4px 8px 0; background: var(--c-chrome-2); } +.drawer-tabs button { height: 24px; border: 1px solid var(--c-line); border-bottom: none; background: #dfe2e7; color: var(--c-ink-2); padding: 0 12px; border-radius: var(--r) var(--r) 0 0; cursor: pointer; font: inherit; font-size: 12px; } +.drawer-tabs button:hover { background: #e9ebef; } +.drawer-tabs button.active { background: var(--c-surface); color: var(--c-ink); font-weight: 600; } .drawer-list { overflow-y: auto; padding: 6px 8px; background: #fff; border-top: 1px solid #d5d5d5; } .drawer-group { font-weight: 600; font-size: 12px; margin: 6px 0 2px; color: #555; } .drawer-item { display: block; width: 100%; text-align: left; background: none; border: none; padding: 3px 6px; cursor: pointer; font-size: 12px; color: #333; border-radius: 3px; } @@ -91,19 +149,39 @@ button { font: inherit; } .status-warn { color: #b7791f; font-weight: 600; } .loop-hint { color: #b7791f; } -.toolbar button { padding: 4px 10px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; } -.toolbar button:hover { background: #eef2f7; } +.toolbar > * { flex: none; } +.toolbar button, .toolbar select { + height: 26px; padding: 0 9px; border: 1px solid var(--c-line); + border-radius: var(--r); background: var(--c-surface); color: var(--c-ink); + font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; +} +.toolbar select { padding-right: 4px; } +.toolbar button:hover, .toolbar select:hover { background: var(--c-accent-soft); border-color: #bcc6cd; } +.toolbar button:active { background: #dfeaec; } +.toolbar button:focus-visible, .toolbar select:focus-visible, +.palette-entry:focus-visible, .sheet-tab:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; } +/* icon-only buttons stay square so the row reads as a grid, not a ransom note */ +.toolbar button.tb-icon { width: 26px; padding: 0; font-size: 13px; } + +/* zoom: one segmented control instead of three loose buttons */ +.tb-zoom { display: inline-flex; align-items: center; } +.tb-zoom button { border-radius: 0; margin-left: -1px; } +.tb-zoom button:first-child { border-radius: var(--r) 0 0 var(--r); margin-left: 0; } +.tb-zoom button:last-child { border-radius: 0 var(--r) var(--r) 0; } +.tb-zoom button:not(.tb-zoom-pct) { width: 26px; padding: 0; font-size: 14px; } +.tb-zoom-pct { min-width: 50px; font-variant-numeric: tabular-nums; color: var(--c-ink-2); } .app-name { margin-right: 4px; } .doc-name { color: #666; font-size: 12px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.tb-sep { width: 1px; height: 20px; background: #d5d5d5; margin: 0 2px; } +.tb-sep { width: 1px; height: 18px; background: var(--c-line); margin: 0 4px; flex: none; } .tb-grow { flex: 1; } .tb-line { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #555; } .tb-line select { font: inherit; padding: 3px 4px; border: 1px solid #ccc; border-radius: 4px; max-width: 150px; } -.sheet-tabs { display: flex; align-items: center; gap: 2px; padding: 3px 8px 0; background: #ececf0; border-top: 1px solid #d5d5d5; overflow-x: auto; } -.sheet-tab { display: flex; align-items: center; gap: 4px; padding: 3px 10px; border: 1px solid #d5d5d5; border-bottom: none; border-radius: 6px 6px 0 0; background: #e0e0e6; font-size: 12px; cursor: pointer; white-space: nowrap; } -.sheet-tab.active { background: #fff; font-weight: 600; } -.sheet-tab input { font: inherit; width: 90px; border: 1px solid #aaa; border-radius: 3px; } +.sheet-tabs { display: flex; align-items: flex-end; gap: 2px; padding: 4px 8px 0; background: var(--c-chrome-2); border-top: 1px solid var(--c-line); overflow-x: auto; flex: none; } +.sheet-tab { display: flex; align-items: center; gap: 5px; height: 25px; padding: 0 10px; border: 1px solid var(--c-line); border-bottom: none; border-radius: var(--r) var(--r) 0 0; background: #dfe2e7; color: var(--c-ink-2); font-size: 12px; cursor: pointer; white-space: nowrap; } +.sheet-tab:hover { background: #e9ebef; } +.sheet-tab.active { background: var(--c-surface); color: var(--c-ink); font-weight: 600; box-shadow: 0 1px 0 var(--c-surface); } +.sheet-tab input { font: inherit; width: 92px; border: 1px solid var(--c-line); border-radius: 3px; padding: 1px 4px; } .sheet-close { border: none; background: none; cursor: pointer; color: #888; padding: 0 2px; font-size: 13px; } .sheet-close:hover { color: #c00; } .sheet-add { border: none; background: none; cursor: pointer; padding: 2px 8px; font-size: 13px; color: #555; } @@ -147,7 +225,7 @@ button { font: inherit; } .export-menu { position: relative; } .export-menu > button.on { background: #dbeafe; border-color: #2b6cb0; } -.export-pop { position: absolute; right: 0; top: calc(100% + 4px); z-index: 35; background: #fff; border: 1px solid #c5c5d5; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); padding: 6px; min-width: 190px; } +.export-pop { z-index: 60; background: #fff; border: 1px solid #c5c5d5; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); padding: 6px; min-width: 190px; } .export-title { font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #889; padding: 6px 8px 2px; } .export-item { display: block; width: 100%; text-align: left; border: none; background: none; padding: 5px 8px; border-radius: 4px; cursor: pointer; font: inherit; font-size: 12.5px; color: #223; } .export-item:hover { background: #eef2f7; color: #2b6cb0; } @@ -202,3 +280,324 @@ button { font: inherit; } .line-popover button { border: 1px solid #ccc; background: #fff; border-radius: 4px; cursor: pointer; padding: 2px 7px; font-size: 12px; } .line-popover button:hover { background: #eef2f7; } .line-popover button.on { background: #dbeafe; border-color: #2b6cb0; color: #2b6cb0; } + +/* --- Budget & cost estimate dialog (panels/BudgetDialog.tsx) --- */ +.bd { display: flex; flex-direction: column; gap: 12px; } +.bd-summary { display: flex; gap: 28px; flex-wrap: wrap; padding: 12px 14px; border: 1px solid #e2e2e8; border-radius: 8px; background: #fafafc; } +.bd-figure { display: flex; flex-direction: column; gap: 1px; } +.bd-k { font-size: 10px; letter-spacing: .09em; text-transform: uppercase; color: #8a95a0; } +.bd-total { font-size: 25px; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; color: #16202a; } +.bd-total.bd-good { color: #2f7d55; } +.bd-total.bd-bad { color: #c53030; } +.bd-sub { font-size: 11.5px; color: #7a8590; font-variant-numeric: tabular-nums; } +.bd-bar { height: 6px; border-radius: 999px; background: #e6e8ec; overflow: hidden; } +.bd-bar-fill { height: 100%; background: #2f7d55; border-radius: 999px; transition: width .18s ease; } +.bd-bar-fill.bd-bar-over { background: #c53030; } + +.bd-controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; } +.bd-controls label { display: flex; flex-direction: column; gap: 3px; } +.bd-controls .bd-grow { flex: 1; min-width: 200px; } +.bd-controls input, .bd-controls select { font: inherit; font-size: 13px; padding: 5px 7px; border: 1px solid #ccc; border-radius: 4px; background: #fff; color: #16202a; } +.bd-controls input { width: 130px; text-align: right; font-variant-numeric: tabular-nums; } +.bd-controls .bd-grow select { width: 100%; } + +.bd-table-wrap { max-height: 320px; overflow-y: auto; border: 1px solid #d5d5da; border-radius: 6px; } +.bd-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.bd-table thead th { position: sticky; top: 0; z-index: 1; background: #f2f2f6; text-align: left; font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: #8a95a0; font-weight: 600; padding: 7px 9px; border-bottom: 1px solid #d5d5da; } +.bd-table th.r, .bd-table td.r { text-align: right; } +.bd-table tbody tr { border-top: 1px solid #ececf0; } +.bd-table tbody tr:hover { background: #fafafc; } +.bd-table td { padding: 6px 9px; vertical-align: top; } +.bd-num { font-variant-numeric: tabular-nums; white-space: nowrap; } +.bd-item { font-weight: 500; } +.bd-basis { display: block; font-size: 10.5px; color: #9aa4ae; line-height: 1.3; max-width: 330px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bd-est { display: inline-block; margin-left: 6px; padding: 0 4px; border-radius: 2px; background: #f4ebdb; color: #9a6a15; font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase; vertical-align: 1px; cursor: help; } +.bd-unit { width: 92px; text-align: right; font: inherit; font-size: 12.5px; font-variant-numeric: tabular-nums; padding: 3px 5px; border: 1px solid #d5d5da; border-radius: 3px; background: #fff; color: #16202a; } +.bd-unit.bd-overridden { font-weight: 700; border-color: #0e6e7a; background: #f2fafb; } +.bd-reset { padding: 1px 6px; border: 1px solid #ccc; border-radius: 3px; background: #fff; font: inherit; font-size: 12px; cursor: pointer; color: #556; } +.bd-reset:hover { background: #eef2f7; } +.bd-empty { padding: 16px; color: #889; text-align: center; } + +.bd-warn { margin: 0; font-size: 11.5px; color: #9a6a15; } +.bd-foot { display: flex; gap: 14px; align-items: flex-end; } +.bd-fine { margin: 0; flex: 1; font-size: 10.5px; line-height: 1.45; color: #98a2ac; } +.bd-csv { white-space: nowrap; padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; background: #fff; font: inherit; font-size: 12.5px; cursor: pointer; } +.bd-csv:hover { background: #eef2f7; } +.bd-target { display: flex; gap: 4px; } +.bd-target input { width: 96px; } +.bd-target select { padding: 5px 4px; } +.bd-hint { font-size: 10.5px; color: #8a95a0; font-variant-numeric: tabular-nums; margin-top: 2px; } + +/* --- per-component price block in the property panel --- */ +.prop-cost { gap: 4px; } +.prop-cost-head { display: flex; align-items: center; gap: 5px; } +.prop-cost-row { display: flex; gap: 4px; align-items: center; } +.prop-cost-row input { flex: 1; min-width: 0; text-align: right; font-variant-numeric: tabular-nums; } +.prop-cost-reset { padding: 3px 7px; border: 1px solid #ccc; border-radius: 4px; background: #fff; font: inherit; font-size: 12px; cursor: pointer; color: #556; } +.prop-cost-reset:hover { background: #eef2f7; } +.prop-cost-note { font-size: 10.5px; color: #8a95a0; text-transform: none; font-variant-numeric: tabular-nums; } +.prop-cost-note.prop-cost-on { color: #0e6e7a; font-weight: 600; } +.prop-cost-basis { font-size: 10px; line-height: 1.35; color: #a4adb6; text-transform: none; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.prop-est { padding: 0 4px; border-radius: 2px; background: #f4ebdb; color: #9a6a15; font-size: 9px; letter-spacing: .05em; text-transform: uppercase; cursor: help; } + +/* the palette's own chrome, aligned to the panel header above it */ +.palette-import { display: block; width: calc(100% - 12px); margin: 0 6px 8px; height: 24px; border: 1px dashed #b9c0c9; border-radius: var(--r); background: none; font: inherit; font-size: 11.5px; color: var(--c-ink-2); cursor: pointer; } +.palette-import:hover { background: var(--c-surface); border-style: solid; border-color: var(--c-accent); color: var(--c-accent); } +.palette-cat { display: flex; align-items: center; gap: 5px; width: 100%; text-align: left; background: none; border: none; padding: 5px 10px; font: inherit; font-size: 11.5px; font-weight: 600; cursor: pointer; color: var(--c-ink); } +.palette-cat:hover { color: var(--c-accent); } +.palette-entry { border-radius: var(--r); } +.palette-entry:hover { border-color: var(--c-accent); background: var(--c-surface); } +.palette-search { border: 1px solid var(--c-line); border-radius: var(--r); font-size: 12px; } +.palette-search:focus-visible { outline: 2px solid var(--c-accent); outline-offset: -1px; border-color: var(--c-accent); } +.prop-title { font-weight: 600; font-size: 13px; margin: 2px 0 8px; color: var(--c-ink); } +.prop-field { color: var(--c-ink-2); } +.prop-field input, .prop-field select { border: 1px solid var(--c-line); border-radius: var(--r); } +.prop-field input:focus-visible, .prop-field select:focus-visible { outline: 2px solid var(--c-accent); outline-offset: -1px; border-color: var(--c-accent); } + +/* Toolbar must hold ONE line at normal widths — a wrapped toolbar shifts the + canvas down and reads as broken alignment. Least-important items give up + their labels first; nothing is ever clipped because the row can still wrap + as a last resort on very narrow windows. */ +.toolbar .tb-template { max-width: 124px; } +.toolbar .tb-line select { max-width: 148px; } +.tb-line { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--c-ink-2); white-space: nowrap; } +@media (max-width: 1560px) { +} +@media (max-width: 1360px) { + .toolbar .tb-line-label { display: none; } + .toolbar .tb-template { max-width: 104px; } +} +@media (max-width: 1440px) { + .toolbar .app-name { display: none; } + .toolbar .doc-name { max-width: 132px; } +} +@media (max-width: 1240px) { + .toolbar { gap: 3px; padding: 4px 6px; } + .toolbar button, .toolbar select { padding: 0 7px; } + .toolbar .doc-name { max-width: 96px; } +} +/* collapsed coffee button keeps the toolbar's button geometry, not a blob */ +@media (max-width: 1560px) { +} + +/* Route-level suspense fallback — shown while the editor or homepage chunk loads. */ +.route-loading { + display: grid; + place-items: center; + height: 100%; + color: var(--c-ink-3); + font: 13px/1.45 system-ui, sans-serif; +} + +/* Account control in the toolbar. */ +.tb-account-wrap { position: relative; display: flex; align-items: center; gap: 6px; } +.tb-account { + display: inline-flex; align-items: center; gap: 4px; + padding: 0 6px; white-space: nowrap; +} +/* On a narrow window the tool row matters more than the branding: the app + name is the one thing you already know once you are inside the editor. */ +@media (max-width: 1360px) { + .app-name { display: none; } + /* ~20 controls: a pixel off each gap is the difference between the row + fitting a 1280 laptop and scrolling. */ + .toolbar { gap: 3px; padding-left: 8px; padding-right: 8px; } + .toolbar button, .toolbar select { padding: 0 8px; } +} + +.tb-account-who { + padding: 7px 10px 5px; font-size: 11.5px; color: var(--c-ink-3); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; +} +.tb-account-caret { color: var(--c-ink-3); font-size: 9px; } +.tb-account-menu a.tb-account-link { + display: block; text-align: left; padding: 7px 10px; border-radius: 4px; + color: var(--c-ink); text-decoration: none; font: inherit; +} +.tb-account-menu a.tb-account-link:hover { background: var(--c-accent-soft); } +.tb-account-dot { + display: grid; place-items: center; + width: 18px; height: 18px; border-radius: 50%; + background: var(--c-accent); color: #fff; + font-size: 10px; font-weight: 700; flex: none; +} +.tb-account-menu { + z-index: 60; + min-width: 176px; padding: 4px; + display: flex; flex-direction: column; + background: var(--c-surface); border: 1px solid var(--c-line); + border-radius: var(--r); box-shadow: 0 10px 28px rgba(0, 0, 0, .18); +} +.tb-account-menu button { + text-align: left; border: 0; background: none; padding: 7px 10px; + border-radius: 4px; font: inherit; color: var(--c-ink); cursor: pointer; +} +.tb-account-menu button:hover:not(:disabled) { background: var(--c-accent-soft); } +.tb-account-menu button:disabled { color: var(--c-ink-3); cursor: default; } +.tb-account-sep { height: 1px; margin: 4px 6px; background: var(--c-line-soft); } +.tb-account-status { font-size: 11.5px; color: var(--c-ink-2); white-space: nowrap; } + +/* Version + "we ship often" note. Lives in both status bars (P&ID and HMI); + quiet by default, since it is ambient information, not an alert. */ +.version-chip { + border: 1px solid transparent; background: none; + padding: 1px 6px; border-radius: 999px; + font: inherit; font-size: 11px; color: var(--c-ink-3); + cursor: pointer; white-space: nowrap; +} +.version-chip:hover:not(:disabled) { + color: var(--c-ink); border-color: var(--c-line); background: var(--c-surface); +} +.version-chip:disabled { cursor: default; opacity: .7; } +.hmi-status .version-chip { color: inherit; opacity: .75; } +.hmi-status .version-chip:hover:not(:disabled) { opacity: 1; background: rgba(255, 255, 255, .12); border-color: transparent; } + +/* ── workspace shell ────────────────────────────────────────────────────── + The rail is the editor's top-level navigation. It sits outside .app so it + survives switching workspaces, and .app keeps its own grid untouched. */ +.shell { + height: 100%; + display: grid; + grid-template-columns: var(--rail-w) minmax(0, 1fr); + --rail-w: 60px; +} +.shell-body { min-width: 0; min-height: 0; overflow: hidden; } +.shell-body > .app { height: 100%; } + +.rail { + display: flex; flex-direction: column; gap: 2px; + padding: 6px 5px; background: var(--c-chrome-2); + border-right: 1px solid var(--c-line); overflow-y: auto; +} +.rail-btn { + position: relative; + display: flex; flex-direction: column; align-items: center; gap: 2px; + padding: 7px 2px 6px; border: 1px solid transparent; border-radius: var(--r); + background: none; color: var(--c-ink-2); cursor: pointer; font: inherit; +} +.rail-btn:hover { background: var(--c-surface); color: var(--c-ink); border-color: var(--c-line-soft); } +.rail-btn.on { background: var(--c-accent-soft); color: var(--c-accent); border-color: #cfe2e5; font-weight: 600; } +.rail-btn:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; } +.rail-icon { font-size: 16px; line-height: 1; } +.rail-label { font-size: 9.5px; letter-spacing: .02em; } +.rail-badge { + position: absolute; top: 3px; right: 5px; min-width: 15px; padding: 0 3px; + border-radius: 8px; background: var(--c-bad); color: #fff; + font-size: 9px; font-weight: 700; line-height: 15px; text-align: center; +} + +/* Under a narrow window the rail keeps the icons and drops the words. */ +@media (max-width: 1100px) { + .shell { --rail-w: 40px; } + .rail-label { display: none; } +} + +/* ── full-window workspaces (Data, Checks) ───────────────────────────────── */ +.ws { height: 100%; display: flex; flex-direction: column; background: var(--c-panel); min-height: 0; } +.ws-head { + display: flex; align-items: center; gap: 12px; flex: none; + padding: 0 14px; min-height: var(--tb-h); + border-bottom: 1px solid var(--c-line); background: var(--c-chrome); +} +.ws-head h1 { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: -.01em; } +.ws-head button { + height: 26px; padding: 0 10px; border: 1px solid var(--c-line); border-radius: var(--r); + background: var(--c-surface); color: var(--c-ink); font: inherit; font-size: 12px; cursor: pointer; +} +.ws-head button:hover { background: var(--c-accent-soft); border-color: #bcc6cd; } +.ws-sp { flex: 1; } +.ws-tabs { display: flex; gap: 3px; } +.ws-tabs button.on { background: var(--c-accent-soft); border-color: #bcc6cd; color: var(--c-accent); font-weight: 600; } +.ws-count { color: var(--c-ink-3); font-variant-numeric: tabular-nums; margin-left: 4px; } +.ws-tabs button.on .ws-count { color: var(--c-accent); } +.ws-tally { font-size: 12px; color: var(--c-ink-2); } +.ws-tally.bad { color: var(--c-bad); font-weight: 600; } +.ws-tally.ok { color: #2c6e4a; } +.ws-tally.soft { color: var(--c-warn); } +.ws-body { flex: 1 1 0; min-height: 0; overflow: auto; padding: 14px; } +.ws-empty { color: var(--c-ink-3); font-size: 13px; max-width: 62ch; } + +.ws-table-wrap { overflow-x: auto; border: 1px solid var(--c-line); border-radius: var(--r); background: var(--c-surface); } +.ws-table { border-collapse: collapse; width: 100%; font-size: 12.5px; } +.ws-table th { + text-align: left; padding: 8px 10px; white-space: nowrap; + font-size: 9.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; + color: var(--c-ink-3); background: var(--c-chrome); border-bottom: 1px solid var(--c-line); + position: sticky; top: 0; z-index: 1; +} +.ws-table td { padding: 7px 10px; border-bottom: 1px solid var(--c-line-soft); vertical-align: top; } +.ws-table tr:last-child td { border-bottom: none; } +.ws-table tr:hover td { background: var(--c-accent-soft); } +.ws-key { font-weight: 600; white-space: nowrap; } +.ws-jump { text-align: right; } +.ws-jump button { + padding: 2px 8px; border: 1px solid var(--c-line); border-radius: 4px; + background: var(--c-surface); font: inherit; font-size: 11px; cursor: pointer; color: var(--c-ink-2); +} +.ws-jump button:hover { background: var(--c-surface); border-color: var(--c-accent); color: var(--c-accent); } + +.ws-sect { margin-bottom: 26px; max-width: 900px; } +.ws-sect-head { margin: 0 0 2px; font-size: 13px; font-weight: 700; } +.ws-sect-head span { color: var(--c-ink-3); font-weight: 500; } +.ws-sect-head.bad { color: var(--c-bad); } +.ws-sect-head.soft { color: var(--c-warn); } +.ws-sect-note { margin: 0 0 10px; font-size: 11.5px; color: var(--c-ink-3); } +.ws-group { margin-bottom: 12px; border: 1px solid var(--c-line); border-radius: var(--r); background: var(--c-surface); overflow: hidden; } +.ws-group-head { + padding: 6px 10px; font-size: 11px; font-weight: 700; color: var(--c-ink-2); + background: var(--c-chrome); border-bottom: 1px solid var(--c-line-soft); +} +.ws-group-head span { color: var(--c-ink-3); font-weight: 500; } +.ws-issue { display: flex; align-items: center; gap: 6px; padding: 0 6px 0 0; border-bottom: 1px solid var(--c-line-soft); } +.ws-issue:last-child { border-bottom: none; } +.ws-issue-msg { flex: 1; text-align: left; padding: 7px 10px; border: none; background: none; font: inherit; font-size: 12.5px; color: var(--c-ink); cursor: pointer; } +.ws-issue-msg:hover { background: var(--c-accent-soft); color: var(--c-accent); } +.ws-issue-fix { padding: 2px 9px; border: 1px solid var(--c-line); border-radius: 4px; background: var(--c-surface); font: inherit; font-size: 11px; cursor: pointer; } +.ws-issue-fix:hover { border-color: var(--c-accent); color: var(--c-accent); } + +/* ── inspector tabs ──────────────────────────────────────────────────────── */ +.insp-tabs { display: flex; gap: 2px; padding: 5px 8px 0; background: var(--c-chrome); border-bottom: 1px solid var(--c-line-soft); } +.insp-tabs button { + flex: 1; height: 23px; padding: 0 6px; border: 1px solid var(--c-line); border-bottom: none; + border-radius: var(--r) var(--r) 0 0; background: #dfe2e7; color: var(--c-ink-2); + font: inherit; font-size: 11.5px; cursor: pointer; +} +.insp-tabs button:hover { background: #e9ebef; } +.insp-tabs button.on { background: var(--c-panel); color: var(--c-ink); font-weight: 600; } + +.used-group { margin-bottom: 12px; } +.used-head { font-size: 10px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: var(--c-ink-3); margin-bottom: 4px; } +.used-empty { font-size: 11.5px; color: var(--c-ink-3); font-style: italic; padding: 2px 0 4px; } +.used-row { + display: flex; align-items: baseline; gap: 8px; width: 100%; text-align: left; + padding: 4px 6px; border: none; border-radius: 3px; background: none; font: inherit; font-size: 12px; color: var(--c-ink); +} +button.used-row { cursor: pointer; } +button.used-row:hover { background: var(--c-accent-soft); color: var(--c-accent); } +.used-row b { font-weight: 600; } +.used-row span { color: var(--c-ink-3); font-size: 11px; margin-left: auto; } + +.drawer-more { + display: block; width: 100%; margin-top: 6px; padding: 5px; + border: 1px dashed var(--c-line); border-radius: var(--r); background: none; + font: inherit; font-size: 11.5px; color: var(--c-ink-2); cursor: pointer; +} +.drawer-more:hover { border-style: solid; border-color: var(--c-accent); color: var(--c-accent); background: var(--c-surface); } + +/* ── engineering record (inspector) ──────────────────────────────────────── */ +.eng-head { display: flex; align-items: center; gap: 8px; margin: 8px 0 6px; } +.eng-head b { font-size: 13px; } +.eng-status { font: inherit; font-size: 11px; padding: 2px 4px; border: 1px solid var(--c-line); border-radius: 4px; background: var(--c-surface); margin-left: auto; } +.eng-progress { position: relative; height: 14px; border-radius: 7px; background: var(--c-chrome-2); overflow: hidden; margin-bottom: 12px; } +.eng-progress span { display: block; height: 100%; background: var(--c-accent); opacity: .28; transition: width .18s ease; } +.eng-progress em { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + font-style: normal; font-size: 9.5px; font-variant-numeric: tabular-nums; color: var(--c-ink-2); +} +@media (prefers-reduced-motion: reduce) { .eng-progress span { transition: none; } } +.eng-section { margin-bottom: 10px; } +.eng-field { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; font-size: 11.5px; color: var(--c-ink-2); } +.eng-field input { font: inherit; font-size: 12px; padding: 3px 6px; border: 1px solid var(--c-line); border-radius: 4px; color: var(--c-ink); } +.eng-field input:focus-visible { outline: 2px solid var(--c-accent); outline-offset: -1px; border-color: var(--c-accent); } +.eng-untagged { padding: 10px 2px; font-size: 12px; color: var(--c-ink-2); } +.eng-untagged p { margin-bottom: 8px; } diff --git a/src/assist/fixes.ts b/src/assist/fixes.ts index cc682d5..6cc23b9 100644 --- a/src/assist/fixes.ts +++ b/src/assist/fixes.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { ulid } from 'ulid' import type { FixSpec } from '../validate/suggest' import type { PlantEdge, PlantNode } from '../model/types' @@ -14,6 +18,10 @@ const snap8 = (v: number) => Math.round(v / 8) * 8 * converter automatically — one undo step. */ export function applyFix(fix: FixSpec): void { + if (fix.kind === 'purge-record') { + useStore.getState().purgeRecord(fix.key) + return + } if (fix.kind !== 'insert-ip') return const s = useStore.getState() if (s.activeSheetId !== fix.sheetId) s.setActiveSheet(fix.sheetId) diff --git a/src/assist/typicals.ts b/src/assist/typicals.ts index 2e75b6b..a7b147f 100644 --- a/src/assist/typicals.ts +++ b/src/assist/typicals.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { ulid } from 'ulid' import type { PlantEdge, PlantNode, ProjectDoc } from '../model/types' import { sharedLoopNumber } from '../isa/autonumber' diff --git a/src/auth/AuthForm.tsx b/src/auth/AuthForm.tsx new file mode 100644 index 0000000..2cfc319 --- /dev/null +++ b/src/auth/AuthForm.tsx @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { register, resetPassword, signIn, signInWithGoogle } from './authStore' +import { authErrorMessage } from './errors' +import './auth.css' + +type Mode = 'signin' | 'register' + +/** Firebase rejects anything shorter, and only after a round trip. Checked in + * JS rather than with minLength so the message lands in the dialog's own error + * area, next to everything else that can go wrong here. */ +const MIN_PASSWORD = 6 + +/** Sign in, create an account, or reset a password. Rendered by AuthScreen, + * which stands in front of the editor — so `onDone` means "you are in", not + * "close me". Kept separate from the screen so the form can be dropped + * anywhere else later without dragging the page chrome with it. */ +export default function AuthForm({ onDone, initialMode = 'signin' }: { + onDone(): void + initialMode?: Mode +}) { + const [mode, setMode] = useState(initialMode) + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [pending, setPending] = useState(false) + const nameRef = useRef(null) + const emailRef = useRef(null) + + useEffect(() => { + const first = mode === 'register' ? nameRef.current : emailRef.current + first?.focus() + }, [mode]) + + const attempt = async (action: () => Promise) => { + setError('') + setNotice('') + setPending(true) + try { + await action() + } catch (err) { + setError(authErrorMessage(err)) + } finally { + setPending(false) + } + } + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + if (password.length < MIN_PASSWORD) { + setError(`Passwords need at least ${MIN_PASSWORD} characters.`) + return + } + void attempt(async () => { + if (mode === 'signin') await signIn(email, password) + else await register(email, password, name) + onDone() + }) + } + + const onGoogle = () => { + void attempt(async () => { + await signInWithGoogle() + onDone() + }) + } + + const onForgot = () => { + if (!email.trim()) { + setError('Enter your email address first, then choose Forgot password.') + emailRef.current?.focus() + return + } + void attempt(async () => { + await resetPassword(email) + setNotice(`Reset link sent to ${email.trim()}. Check your inbox, and the spam folder.`) + }) + } + + // Switching tabs drops the last message: a sign-in error is not an answer to + // the form the user just moved to. + const switchTo = (next: Mode) => { + setMode(next) + setError('') + setNotice('') + } + + const signup = mode === 'register' + + return ( +
+
+ + +
+ +
+ {signup && ( +
+ + setName(e.target.value)} + /> +
+ )} + +
+ + setEmail(e.target.value)} + /> +
+ +
+ + setPassword(e.target.value)} + /> + {signup && At least {MIN_PASSWORD} characters.} +
+ + {error &&

{error}

} + {notice &&

{notice}

} + + + + {!signup && ( +
+ +
+ )} +
+ +
or
+ + + +

+ IPD Studio is free for personal, academic, nonprofit and government use. + Commercial use requires a paid licence. +

+
+ ) +} diff --git a/src/auth/AuthScreen.tsx b/src/auth/AuthScreen.tsx new file mode 100644 index 0000000..fd9e3fa --- /dev/null +++ b/src/auth/AuthScreen.tsx @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import AuthForm from './AuthForm' +import { navigate } from '../routes' +import './authScreen.css' + +/** Stands in front of the editor. IPD Studio requires an account, so this is + * the first thing most people see — it carries the product's name and what it + * is, not just a bare form on an empty page. */ +export default function AuthScreen() { + return ( +
+
+
+ +

Sign in to keep drawing

+

+ Your P&IDs, HMI screens and cost estimates live in your account, so they open on + whatever machine you sit down at. +

+
    +
  • ISA-5.1 tags parsed and validated as you draw
  • +
  • Instrument index and line list generated from the model
  • +
  • Operator screens you can run as a live simulation
  • +
+
+ +
+ { /* the auth listener swaps this screen for the editor */ }} /> +
+
+
+ ) +} diff --git a/src/auth/auth.css b/src/auth/auth.css new file mode 100644 index 0000000..25304f9 --- /dev/null +++ b/src/auth/auth.css @@ -0,0 +1,67 @@ +/* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; + * commercial use requires a paid license (see COMMERCIAL-LICENSE.md). */ + +.auth { display: flex; flex-direction: column; gap: 14px; } + +.auth-tabs { display: flex; gap: 16px; border-bottom: 1px solid var(--c-line); } +.auth-tab { + border: none; background: none; padding: 6px 0 7px; margin-bottom: -1px; + border-bottom: 2px solid transparent; + font: inherit; font-size: 12.5px; color: var(--c-ink-2); cursor: pointer; +} +.auth-tab:hover { color: var(--c-accent); } +.auth-tab[aria-selected='true'] { color: var(--c-accent); border-bottom-color: var(--c-accent); font-weight: 600; } +.auth-tab:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; border-radius: 2px; } + +.auth-form { display: flex; flex-direction: column; gap: 11px; } +.auth-field { display: flex; flex-direction: column; gap: 3px; } +.auth-field label { font-size: 10px; letter-spacing: .09em; text-transform: uppercase; color: var(--c-ink-3); } +.auth-field input { + font: inherit; font-size: 13px; padding: 7px 9px; + border: 1px solid var(--c-line); border-radius: var(--r); + background: var(--c-surface); color: var(--c-ink); +} +.auth-field input:focus-visible { outline: 2px solid var(--c-accent); outline-offset: -1px; border-color: var(--c-accent); } +.auth-hint { font-size: 10.5px; color: var(--c-ink-3); } + +.auth-submit { + height: 34px; border: 1px solid var(--c-accent); border-radius: var(--r); + background: var(--c-accent); color: #fff; + font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; +} +.auth-submit:hover:not(:disabled) { background: #0b5b65; border-color: #0b5b65; } +.auth-submit:disabled { opacity: .55; cursor: default; } + +.auth-row { display: flex; justify-content: flex-end; } +.auth-link { + border: none; background: none; padding: 0; font: inherit; font-size: 11.5px; + color: var(--c-accent); text-decoration: underline; cursor: pointer; +} +.auth-link:hover:not(:disabled) { color: var(--c-ink); } +.auth-link:disabled { opacity: .55; cursor: default; } + +.auth-or { + display: flex; align-items: center; gap: 10px; + font-size: 10px; letter-spacing: .09em; text-transform: uppercase; color: var(--c-ink-3); +} +.auth-or::before, .auth-or::after { content: ''; flex: 1; height: 1px; background: var(--c-line-soft); } + +.auth-google { + display: flex; align-items: center; justify-content: center; gap: 9px; + height: 34px; border: 1px solid var(--c-line); border-radius: var(--r); + background: var(--c-surface); color: var(--c-ink); + font: inherit; font-size: 13px; cursor: pointer; +} +.auth-google:hover:not(:disabled) { background: var(--c-accent-soft); border-color: var(--c-accent); } +.auth-google:disabled { opacity: .55; cursor: default; } +.auth-google svg { flex: none; } + +.auth-error, .auth-note { + margin: 0; padding: 8px 10px; border-radius: var(--r); + font-size: 12px; line-height: 1.4; +} +.auth-error { border: 1px solid #f0c8c8; background: #fdf4f4; color: var(--c-bad); } +.auth-note { border: 1px solid #bfdde1; background: var(--c-accent-soft); color: var(--c-accent); } + +.auth-fine { margin: 0; font-size: 10.5px; line-height: 1.45; color: var(--c-ink-3); } diff --git a/src/auth/authScreen.css b/src/auth/authScreen.css new file mode 100644 index 0000000..6f0bf85 --- /dev/null +++ b/src/auth/authScreen.css @@ -0,0 +1,71 @@ +/* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; + * commercial use requires a paid license (see COMMERCIAL-LICENSE.md). */ + +.gate { + display: grid; + place-items: center; + min-height: 100%; + padding: 32px 20px; + background: var(--c-chrome-2); + overflow-y: auto; +} + +.gate-panel { + display: grid; + grid-template-columns: 1fr 380px; + gap: 56px; + align-items: center; + width: 100%; + max-width: 900px; + padding: 40px; + background: var(--c-surface); + border: 1px solid var(--c-line); + border-radius: 10px; + box-shadow: 0 12px 40px rgba(21, 32, 43, .08); +} + +.gate-home { + border: 0; background: none; padding: 0; cursor: pointer; + font: 700 14px/1 system-ui, sans-serif; + letter-spacing: -.01em; color: var(--c-ink-2); +} +.gate-home:hover { color: var(--c-accent); } + +.gate-brand h1 { + margin: 22px 0 0; + font: 600 28px/1.2 system-ui, sans-serif; + letter-spacing: -.02em; + color: var(--c-ink); +} +.gate-brand p { + margin: 12px 0 0; + font-size: 14.5px; line-height: 1.6; color: var(--c-ink-2); + max-width: 42ch; +} +.gate-points { + margin: 22px 0 0; padding: 0; list-style: none; + display: grid; gap: 10px; +} +.gate-points li { + position: relative; padding-left: 22px; + font-size: 13.5px; color: var(--c-ink-2); +} +.gate-points li::before { + content: ""; position: absolute; left: 0; top: 7px; + width: 12px; height: 6px; + border-left: 2px solid var(--c-accent); + border-bottom: 2px solid var(--c-accent); + transform: rotate(-45deg); +} + +.gate-form { min-width: 0; } +/* The form is already a self-contained block; inside the panel it needs no + dialog chrome of its own. */ +.gate-form .auth { padding: 0; } + +@media (max-width: 820px) { + .gate-panel { grid-template-columns: 1fr; gap: 32px; padding: 28px; max-width: 440px; } + .gate-brand h1 { font-size: 24px; } + .gate-points { display: none; } +} diff --git a/src/auth/authStore.ts b/src/auth/authStore.ts new file mode 100644 index 0000000..333ae87 --- /dev/null +++ b/src/auth/authStore.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { create } from 'zustand' +import { + GoogleAuthProvider, + createUserWithEmailAndPassword, + onAuthStateChanged, + sendEmailVerification, + sendPasswordResetEmail, + signInWithEmailAndPassword, + signInWithPopup, + signOut, + updateProfile, + type User, +} from 'firebase/auth' +import { auth } from './firebase' + +export interface AuthState { + user: User | null + /** False until Firebase has answered once. Without it the UI cannot tell + * "still checking the session" from "definitely signed out", and every + * reload flashes the signed-out chrome at a signed-in user. */ + ready: boolean +} + +export const useAuthStore = create()(() => ({ user: null, ready: false })) + +/** Subscribe to session changes; returns the unsubscribe for effect cleanup. */ +export function initAuth(): () => void { + return onAuthStateChanged( + auth(), + (user) => useAuthStore.setState({ user, ready: true }), + () => useAuthStore.setState({ user: null, ready: true }), + ) +} + +export async function signIn(email: string, password: string): Promise { + await signInWithEmailAndPassword(auth(), email.trim(), password) +} + +export async function register(email: string, password: string, displayName?: string): Promise { + const cred = await createUserWithEmailAndPassword(auth(), email.trim(), password) + const name = displayName?.trim() + if (name) { + await updateProfile(cred.user, { displayName: name }) + // A profile edit does not re-fire onAuthStateChanged, so push the fresh + // user through by hand or the greeting shows an empty name until reload. + useAuthStore.setState({ user: auth().currentUser }) + } + // Best effort: a bounced verification email must not cost someone the + // account they just created — they are signed in either way. + try { + await sendEmailVerification(cred.user) + } catch { + // ignored on purpose + } +} + +export async function signInWithGoogle(): Promise { + await signInWithPopup(auth(), new GoogleAuthProvider()) +} + +export async function resetPassword(email: string): Promise { + await sendPasswordResetEmail(auth(), email.trim()) +} + +export async function signOutUser(): Promise { + await signOut(auth()) +} diff --git a/src/auth/config.ts b/src/auth/config.ts new file mode 100644 index 0000000..933e49a --- /dev/null +++ b/src/auth/config.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +/** + * Which Firebase project this build talks to — read from the environment, with + * NO built-in fallback. + * + * A Firebase web config is not a secret: it identifies a project to the client + * and ships in the bundle by design, with access enforced by `firestore.rules` + * and the Auth authorized-domains list. The reason it is env-only is different. + * A hardcoded default would be INHERITED BY EVERY FORK, so a fork's users would + * silently sign into the upstream project — real accounts and real drawings + * landing in a stranger's Firestore, against their quota. Absent config is the + * safe default; `firebaseReady` turns the whole account layer off instead. + * + * Deliberately free of `firebase/*` imports so anything can ask "is there a + * backend?" without pulling the SDK into its chunk. + * + * Copy `.env.example` to `.env.local` to enable accounts. + */ +const env = import.meta.env + +export const firebaseConfig = { + apiKey: env.VITE_FIREBASE_API_KEY, + authDomain: env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: env.VITE_FIREBASE_PROJECT_ID, + storageBucket: env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: env.VITE_FIREBASE_SENDER_ID, + appId: env.VITE_FIREBASE_APP_ID, +} + +/** + * Whether this build has a backend at all. False in a fork with no + * `.env.local`, and in CI. Everything account-shaped — the sign-in gate, the + * account menu, cloud drawings — checks this first; drawing never needed one. + */ +export const firebaseReady: boolean = Boolean( + firebaseConfig.apiKey && firebaseConfig.projectId && firebaseConfig.appId, +) diff --git a/src/auth/errors.ts b/src/auth/errors.ts new file mode 100644 index 0000000..224668f --- /dev/null +++ b/src/auth/errors.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +/** + * Firebase auth codes read like stack traces ("Firebase: Error + * (auth/invalid-credential)."). Users see these at the exact moment they are + * least patient, so every sentence here says what happened and what to do + * next, and none of them blame the person typing. + */ +export const AUTH_MESSAGES: Record = { + 'auth/invalid-email': 'That email address does not look quite right. Check it and try again.', + 'auth/user-disabled': 'This account is disabled. Get in touch if that seems wrong.', + 'auth/user-not-found': 'No account uses that email yet. Create one, or check the address.', + 'auth/wrong-password': 'That password does not match this account. Try again, or use Forgot password.', + 'auth/invalid-credential': 'That email and password do not match an account. Check both, or use Forgot password.', + 'auth/email-already-in-use': 'An account already uses that email. Sign in instead, or reset the password.', + 'auth/weak-password': 'Passwords need at least 6 characters. Pick a longer one.', + 'auth/too-many-requests': 'Too many attempts from this device. Wait a few minutes, then try again.', + 'auth/network-request-failed': 'Could not reach the sign-in server. Check your connection and try again.', + 'auth/popup-closed-by-user': 'The Google window closed before sign-in finished. Try again when you are ready.', + 'auth/popup-blocked': 'The browser blocked the Google window. Allow pop-ups for this site, then try again.', + 'auth/cancelled-popup-request': 'Only one sign-in window can be open at a time. Use the one already open.', + 'auth/operation-not-allowed': 'This sign-in method is not enabled for this project yet.', + 'auth/requires-recent-login': 'For safety this step needs a fresh sign-in. Sign out, sign back in, then try again.', + 'auth/missing-email': 'Enter your email address first.', + 'auth/missing-password': 'Enter your password to continue.', + 'auth/unauthorized-domain': 'Sign-in is not allowed from this address yet. Open the app from its usual URL.', + 'auth/account-exists-with-different-credential': + 'That email already signs in another way. Use the method you set up first, then link Google from your account.', +} + +const GENERIC = 'Something went wrong while signing in. Try again in a moment.' + +function readString(err: unknown, key: 'code' | 'message'): string { + if (typeof err === 'object' && err !== null && key in err) { + const value = (err as Record)[key] + if (typeof value === 'string') return value + } + return '' +} + +/** A readable sentence for any auth failure: mapped code, then the raw + * message (SDK prefix stripped), then a generic apology. */ +export function authErrorMessage(err: unknown): string { + const mapped = AUTH_MESSAGES[readString(err, 'code')] + if (mapped) return mapped + const raw = readString(err, 'message').replace(/^Firebase:\s*/, '').trim() + return raw || GENERIC +} diff --git a/src/auth/firebase.ts b/src/auth/firebase.ts new file mode 100644 index 0000000..e9d46b3 --- /dev/null +++ b/src/auth/firebase.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { initializeApp, type FirebaseApp } from 'firebase/app' +import { getAuth, type Auth } from 'firebase/auth' +import { firebaseConfig, firebaseReady } from './config' + +export { firebaseConfig, firebaseReady } + +let app: FirebaseApp | null = null +let authInstance: Auth | null = null + +/** Lazily created so nothing pays for Firebase until a visitor actually signs + * in or opens their cloud drawings. Firestore deliberately lives in + * `cloud/firestore.ts` — it is the larger half of the SDK and only the + * cloud-drawings feature needs it. */ +export function firebaseApp(): FirebaseApp { + if (!firebaseReady) { + throw new Error( + 'No Firebase project is configured, so accounts and cloud drawings are off. ' + + 'Copy .env.example to .env.local and fill in your own Firebase web config to enable them.', + ) + } + if (!app) app = initializeApp(firebaseConfig) + return app +} + +export function auth(): Auth { + if (!authInstance) authInstance = getAuth(firebaseApp()) + return authInstance +} diff --git a/src/auth/session.ts b/src/auth/session.ts new file mode 100644 index 0000000..052e52b --- /dev/null +++ b/src/auth/session.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +/** Firebase is only fetched once some part of the UI actually needs a session. + * The import is dynamic on purpose: a static one would pull the whole Firebase + * SDK into the entry chunk, so every homepage visitor would download the auth + * stack before reading a headline. Several components can ask independently; + * only the first one subscribes. */ +let started = false + +export function ensureAuth(): void { + if (started) return + started = true + void import('./authStore').then((m) => m.initAuth()) +} diff --git a/src/canvas/Canvas.tsx b/src/canvas/Canvas.tsx index 3397e30..9d448fd 100644 --- a/src/canvas/Canvas.tsx +++ b/src/canvas/Canvas.tsx @@ -1,12 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useEffect, useRef } from 'react' -import { canvasRef, createPaper, zoomAt } from './paperSetup' +import { canvasRef, createPaper, fitView, renderSheet, viewState, zoomAt } from './paperSetup' import { reconcile } from './reconciler' import { decorateLinks } from './decorations' import { attachDropHandling } from './dropHandling' import { attachInteractions, attachMarquee } from './interactions' import { renderUnderlay } from './underlay' import '../symbols/lib/index' -import { sheetPx } from '../model/doc' import { activeSheet, pauseHistory, resumeHistory, useStore } from '../store/store' interface LabelDrag { @@ -32,7 +35,19 @@ export default function Canvas() { const { paper, graph } = createPaper(paperEl, activeSheet(useStore.getState()).sheetSize) canvasRef.paper = paper canvasRef.graph = graph - paper.translate(24, 24) + renderSheet(paper, activeSheet(useStore.getState()).sheetSize) + + // The paper is a viewport now, so it has to track its container: panel + // toggles, window resizes and drawer opens all change the visible box. + // Re-fit only while the user hasn't taken over the view themselves. + viewState.userMoved = false + const ro = new ResizeObserver(() => { + if (!host.clientWidth || !host.clientHeight) return + const wasFitted = !viewState.userMoved + paper.setDimensions(host.clientWidth, host.clientHeight) + if (wasFitted) fitView(paper, graph, activeSheet(useStore.getState()).sheetSize) + }) + ro.observe(host) paper.on('render:done', () => decorateLinks(paper)) const detachDrop = attachDropHandling(host, paper) @@ -57,7 +72,9 @@ export default function Canvas() { prevFluids = s.doc.fluids graph.clear() reconcile(graph, sheet, undefined, colorOf()) + renderSheet(paper, sheet.sheetSize) renderUnderlay(paper, sheet) + fitView(paper, graph, sheet.sheetSize) } else if (sheet !== prevSheet || s.doc.fluids !== prevFluids) { const before = prevSheet prevSheet = sheet @@ -144,6 +161,7 @@ export default function Canvas() { } if (!panning) return const t = paper.translate() + viewState.userMoved = true paper.translate(t.tx + e.clientX - last.x, t.ty + e.clientY - last.y) last = { x: e.clientX, y: e.clientY } } @@ -182,6 +200,7 @@ export default function Canvas() { window.removeEventListener('keydown', onKey) window.removeEventListener('keyup', onKey) paper.remove() + ro.disconnect() canvasRef.paper = undefined canvasRef.graph = undefined host.innerHTML = '' @@ -189,8 +208,11 @@ export default function Canvas() { }, []) useEffect(() => { - const { w, h } = sheetPx(sheetSize) - canvasRef.paper?.setDimensions(w, h) + const paper = canvasRef.paper + const graph = canvasRef.graph + if (!paper || !graph) return + renderSheet(paper, sheetSize) + fitView(paper, graph, sheetSize) }, [sheetSize]) return
diff --git a/src/canvas/alignment.ts b/src/canvas/alignment.ts index 8c005de..23f2fe5 100644 --- a/src/canvas/alignment.ts +++ b/src/canvas/alignment.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { PlantEdge, PlantNode } from '../model/types' import { isPortEnd } from '../model/types' import { getSymbol } from '../symbols/registry' diff --git a/src/canvas/connectionRules.ts b/src/canvas/connectionRules.ts index 167ec4e..c77110e 100644 --- a/src/canvas/connectionRules.ts +++ b/src/canvas/connectionRules.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { LineClass } from '../model/types' import type { PortKind } from '../symbols/types' import { isProcessClass } from './lineStyle' diff --git a/src/canvas/decorations.ts b/src/canvas/decorations.ts index c8e6868..06612f1 100644 --- a/src/canvas/decorations.ts +++ b/src/canvas/decorations.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { dia, g } from '@joint/core' import type { LineClass } from '../model/types' import { GLYPHS, glyphPointsForRoute } from './glyphs' diff --git a/src/canvas/dropHandling.ts b/src/canvas/dropHandling.ts index 9a96439..86e9e59 100644 --- a/src/canvas/dropHandling.ts +++ b/src/canvas/dropHandling.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { dia } from '@joint/core' import { DRAG_MIME, type DragPayload } from '../panels/Palette' import { getSymbol } from '../symbols/registry' diff --git a/src/canvas/glyphs.ts b/src/canvas/glyphs.ts index 9887008..b5aaf01 100644 --- a/src/canvas/glyphs.ts +++ b/src/canvas/glyphs.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { LineClass } from '../model/types' export interface GlyphStation { diff --git a/src/canvas/interactions.ts b/src/canvas/interactions.ts index daf28fc..abafe17 100644 --- a/src/canvas/interactions.ts +++ b/src/canvas/interactions.ts @@ -1,3 +1,8 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { canvasRef, fitView, zoomActual } from './paperSetup' import { dia, g, highlighters, linkTools } from '@joint/core' import { ulid } from 'ulid' import type { PlantEdge, PlantNode } from '../model/types' @@ -312,6 +317,8 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo // --- movement commit + live alignment guides --------------------------- const dragStart = new Map() + /** Waypoints of the links moving rigidly with the drag, as they were at grab. */ + const dragStartVerts = new Map() const guideEls: SVGLineElement[] = [] const guideLayer = () => paper.svg.querySelector('.joint-layers') as SVGGElement | null const clearGuides = () => { @@ -355,6 +362,10 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo const cell = graph.getCell(nid) as dia.Element | undefined if (cell?.isElement()) cell.position(s0.x + dx, s0.y + dy) } + for (const [lid, v0] of dragStartVerts) { + const link = graph.getCell(lid) as dia.Link | undefined + link?.vertices(v0.map((p) => ({ x: p.x + dx, y: p.y + dy }))) + } } const hit = snapGuides({ ...node, x: p.x, y: p.y }, sheet.nodes, 4, sheet.edges) if (hit.guideX !== undefined) drawGuide(true, hit.guideX) @@ -370,6 +381,17 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo if (cell?.isElement()) dragStart.set(nid, cell.position()) } dragStart.set(id, view.model.position()) + // Links whose both ends are being dragged travel rigidly, so their + // waypoints move with them — live, not just on drop. + dragStartVerts.clear() + for (const link of graph.getLinks()) { + const a = link.getSourceCell()?.id + const b = link.getTargetCell()?.id + if (a && b && dragStart.has(String(a)) && dragStart.has(String(b))) { + const v = link.vertices() + if (v.length) dragStartVerts.set(String(link.id), v.map((p) => ({ x: p.x, y: p.y }))) + } + } } const onElementPointerUp = (view: dia.ElementView) => { clearGuides() @@ -394,6 +416,7 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo store().setNodePos(id, nx, ny) } dragStart.clear() + dragStartVerts.clear() } // --- vertex editing on selected links ---------------------------------- @@ -503,6 +526,17 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo duplicateSelection() return } + // Shift+F fits the sheet to the visible canvas; Shift+1 goes back to 1:1. + if (e.shiftKey && e.key.toLowerCase() === 'f' && canvasRef.paper && canvasRef.graph) { + e.preventDefault() + fitView(canvasRef.paper, canvasRef.graph, activeSheet(s).sheetSize) + return + } + if (e.shiftKey && e.key === '!' && canvasRef.paper) { + e.preventDefault() + zoomActual(canvasRef.paper) + return + } if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); s.deleteSelected(); return } if (e.key === 'Escape') { if (s.armPin) { s.setArmPin(null); return } diff --git a/src/canvas/lineStyle.ts b/src/canvas/lineStyle.ts index 92376dd..51403ae 100644 --- a/src/canvas/lineStyle.ts +++ b/src/canvas/lineStyle.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { LineClass } from '../model/types' export interface LineStroke { diff --git a/src/canvas/markupParser.ts b/src/canvas/markupParser.ts index f214e81..db2e4d9 100644 --- a/src/canvas/markupParser.ts +++ b/src/canvas/markupParser.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + /** * Minimal SVG-string -> JointJS MarkupJSON parser for the restricted subset * emitted by symbol renderers (path/circle/rect/polygon/line/text/g, double- diff --git a/src/canvas/paperSetup.ts b/src/canvas/paperSetup.ts index df8a98e..4cc3e63 100644 --- a/src/canvas/paperSetup.ts +++ b/src/canvas/paperSetup.ts @@ -1,21 +1,53 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { dia, shapes } from '@joint/core' import { sheetPx } from '../model/doc' import type { SheetSize } from '../model/types' -export const MIN_ZOOM = 0.25 +export const MIN_ZOOM = 0.05 export const MAX_ZOOM = 4 -export function createPaper(el: HTMLElement, sheetSize: SheetSize): { paper: dia.Paper; graph: dia.Graph } { +/** Breathing room left around the sheet when fitting, in screen px. */ +const FIT_PADDING = 28 + +const NS = 'http://www.w3.org/2000/svg' + +/** + * Whether the user has taken the view over by panning or zooming. While false, + * the canvas re-fits itself when its container changes size (panel toggles, + * window resizes) — which is what makes "fit to my screen" stay true. Any + * deliberate pan or zoom sets it, and Fit clears it again. + */ +export const viewState = { userMoved: false } + + +/** + * The paper is a VIEWPORT, not the sheet. + * + * It used to be created at the sheet's pixel size — 1587px for an A3, 3178px + * for an A1 — which meant "Fit" asked JointJS to fit the drawing to the sheet + * rather than to the window, and the result was clipped by whatever the panels + * left visible. The paper now fills its container and resizes with it, and the + * sheet is drawn as a white page on the grey desk behind the JointJS layers + * (see `renderSheet`). Pan and zoom are free to move anywhere. + * + * Exports don't read these dimensions — they build their own viewBox from + * sheetPx() — but they do clone paper.svg, so they strip `.pid-sheet`. + */ +export function createPaper(el: HTMLElement, _sheetSize: SheetSize): { paper: dia.Paper; graph: dia.Graph } { const graph = new dia.Graph({}, { cellNamespace: shapes }) - const { w, h } = sheetPx(sheetSize) + const host = el.parentElement const paper = new dia.Paper({ el, model: graph, - width: w, - height: h, + width: host?.clientWidth || 800, + height: host?.clientHeight || 600, gridSize: 8, - drawGrid: { name: 'dot', args: { color: '#c9c9d4', thickness: 1 } }, - background: { color: '#ffffff' }, + // The grid belongs to the sheet, not the desk — renderSheet draws it. + drawGrid: false, + background: { color: 'transparent' }, async: true, sorting: dia.Paper.sorting.APPROX, interactive: { linkMove: false, labelMove: false }, @@ -26,6 +58,45 @@ export function createPaper(el: HTMLElement, sheetSize: SheetSize): { paper: dia return { paper, graph } } +/** + * Draw the drawing sheet: a white page with a hairline border, a soft drop + * shadow to lift it off the desk, and the 8px dot grid clipped to the page. + * Sits behind `.joint-layers` and takes no pointer events. + */ +export function renderSheet(paper: dia.Paper, sheetSize: SheetSize): void { + const svg = paper.svg + svg.querySelector('.pid-sheet')?.remove() + svg.querySelector('#pid-sheet-defs')?.remove() + + const { w, h } = sheetPx(sheetSize) + + const defs = document.createElementNS(NS, 'defs') + defs.setAttribute('id', 'pid-sheet-defs') + defs.innerHTML = + `` + + `` + + `` + + `` + + `` + + `` + + const group = document.createElementNS(NS, 'g') + group.setAttribute('class', 'pid-sheet') + group.setAttribute('pointer-events', 'none') + group.innerHTML = + `` + + `` + + `` + + svg.insertBefore(defs, svg.firstChild) + // Must go INSIDE .joint-layers: that group carries the pan/zoom transform, so + // anything parked next to it instead is drawn unscaled at the SVG origin and + // covers the viewport. Behind the underlay so DXF traces sit on the page. + const layers = svg.querySelector('.joint-layers') + if (!layers) return + layers.insertBefore(group, layers.firstChild) +} + export function clampZoom(scale: number): number { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale)) } @@ -35,6 +106,7 @@ export function zoomAt(paper: dia.Paper, clientX: number, clientY: number, facto const current = paper.scale().sx const next = clampZoom(current * factor) if (next === current) return + viewState.userMoved = true const local = paper.clientToLocalPoint({ x: clientX, y: clientY }) paper.scale(next, next) const after = paper.localToClientPoint(local) @@ -42,5 +114,56 @@ export function zoomAt(paper: dia.Paper, clientX: number, clientY: number, facto paper.translate(t.tx + (clientX - after.x), t.ty + (clientY - after.y)) } +/** Zoom about the centre of the visible viewport. */ +export function zoomCenter(paper: dia.Paper, factor: number): void { + const rect = (paper.el as HTMLElement).getBoundingClientRect() + zoomAt(paper, rect.left + rect.width / 2, rect.top + rect.height / 2, factor) +} + +/** + * Fit the whole page into the visible viewport and centre it. + * + * The target is the sheet unioned with the content bounding box, so a symbol + * dragged off the page still ends up on screen instead of silently outside it. + * Because the paper now matches its container, this is automatically correct + * with the palette and properties panels open, closed, or mid-resize. + */ +export function fitView(paper: dia.Paper, graph: dia.Graph, sheetSize: SheetSize): void { + const el = paper.el as HTMLElement + const vw = el.clientWidth + const vh = el.clientHeight + if (!vw || !vh) return + + const { w, h } = sheetPx(sheetSize) + let x0 = 0 + let y0 = 0 + let x1 = w + let y1 = h + if (graph.getCells().length) { + const b = graph.getBBox() + if (b && b.width >= 0 && b.height >= 0) { + x0 = Math.min(x0, b.x) + y0 = Math.min(y0, b.y) + x1 = Math.max(x1, b.x + b.width) + y1 = Math.max(y1, b.y + b.height) + } + } + const tw = x1 - x0 + const th = y1 - y0 + if (tw <= 0 || th <= 0) return + + const scale = clampZoom(Math.min((vw - FIT_PADDING * 2) / tw, (vh - FIT_PADDING * 2) / th)) + viewState.userMoved = false + paper.scale(scale, scale) + paper.translate((vw - tw * scale) / 2 - x0 * scale, (vh - th * scale) / 2 - y0 * scale) +} + +/** Reset to 1:1 with the sheet's top-left corner just inside the viewport. */ +export function zoomActual(paper: dia.Paper): void { + viewState.userMoved = true + paper.scale(1, 1) + paper.translate(FIT_PADDING, FIT_PADDING) +} + /** Module-scope handle so panels/exports can reach the live paper. */ export const canvasRef: { paper?: dia.Paper; graph?: dia.Graph } = {} diff --git a/src/canvas/reconciler.ts b/src/canvas/reconciler.ts index 1dd6488..d496c7f 100644 --- a/src/canvas/reconciler.ts +++ b/src/canvas/reconciler.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { dia } from '@joint/core' import type { PlantEdge, SheetContent } from '../model/types' import { isPortEnd } from '../model/types' diff --git a/src/canvas/shapes.ts b/src/canvas/shapes.ts index c295649..982775d 100644 --- a/src/canvas/shapes.ts +++ b/src/canvas/shapes.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { dia, shapes } from '@joint/core' import type { PlantEdge, PlantNode } from '../model/types' import { isPortEnd } from '../model/types' diff --git a/src/canvas/underlay.ts b/src/canvas/underlay.ts index f69db5d..0b8e50c 100644 --- a/src/canvas/underlay.ts +++ b/src/canvas/underlay.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { dia } from '@joint/core' import type { Sheet } from '../model/types' @@ -24,7 +28,11 @@ export function renderUnderlay(paper: dia.Paper, sheet: Pick) poly.setAttribute('stroke-width', '1') group.appendChild(poly) } + // Inside .joint-layers so the trace pans and zooms with the drawing; just + // after the sheet page, which is that group's first child. const layers = svg.querySelector('.joint-layers') - if (layers) svg.insertBefore(group, layers) - else svg.appendChild(group) + if (!layers) return + const sheetPage = layers.querySelector(':scope > .pid-sheet') + if (sheetPage) sheetPage.after(group) + else layers.insertBefore(group, layers.firstChild) } diff --git a/src/canvas/vertexClean.ts b/src/canvas/vertexClean.ts index d4a2f7a..72980b8 100644 --- a/src/canvas/vertexClean.ts +++ b/src/canvas/vertexClean.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + export interface Pt { x: number; y: number } const snap8 = (v: number) => Math.round(v / 8) * 8 || 0 diff --git a/src/cloud/CloudDialog.tsx b/src/cloud/CloudDialog.tsx new file mode 100644 index 0000000..d575633 --- /dev/null +++ b/src/cloud/CloudDialog.tsx @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { useEffect, useState, type CSSProperties } from 'react' +import { onAuthStateChanged } from 'firebase/auth' +import { auth } from '../auth/firebase' +import Modal from '../panels/Modal' +import { useStore } from '../store/store' +import { + deleteDrawing, + formatBytes, + listDrawings, + loadFromCloud, + renameDrawing, + saveToCloud, + type CloudDrawingMeta, +} from './sync' + +const btn: CSSProperties = { padding: '3px 9px', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12, cursor: 'pointer' } +const btnPrimary: CSSProperties = { ...btn, borderColor: '#2b6cb0', color: '#2b6cb0' } +const btnDanger: CSSProperties = { ...btn, borderColor: '#c53030', color: '#9b1c1c' } + +function relTime(ms: number): string { + const secs = Math.max(0, Date.now() - ms) / 1000 + if (secs < 90) return 'just now' + if (secs < 3600) return `${Math.round(secs / 60)} min ago` + if (secs < 86_400) return `${Math.round(secs / 3600)} h ago` + if (secs < 7 * 86_400) return `${Math.round(secs / 86_400)} d ago` + return new Date(ms).toLocaleDateString() +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : 'Something went wrong. Try again.' +} + +/** The signed-in user's drawings in Firestore: open, rename, delete, and save + * the drawing on screen — either over the one it came from or as a new one. */ +export default function CloudDialog({ open, onClose }: { open: boolean; onClose(): void }) { + const dirty = useStore((s) => s.dirty) + const cloudId = useStore((s) => s.cloudId) + const setCloudId = useStore((s) => s.setCloudId) + const loadIntoStore = useStore((s) => s.loadIntoStore) + const markSaved = useStore((s) => s.markSaved) + + // undefined = Firebase has not said yet who is signed in + const [uid, setUid] = useState(undefined) + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(null) + const [confirming, setConfirming] = useState<{ kind: 'open' | 'delete'; id: string } | null>(null) + const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null) + const [reload, setReload] = useState(0) + + useEffect(() => { + if (!open) return + return onAuthStateChanged(auth(), (user) => setUid(user ? user.uid : null)) + }, [open]) + + useEffect(() => { + if (!open || !uid) return + let live = true + setRows(null) + setError(null) + listDrawings(uid).then( + (list) => { if (live) setRows(list) }, + (err: unknown) => { if (live) setError(message(err)) }, + ) + return () => { live = false } + }, [open, uid, reload]) + + // a fresh open should not inherit the last visit's half-finished prompts + useEffect(() => { + if (open) return + setConfirming(null) + setRenaming(null) + setBusy(null) + }, [open]) + + if (!open) return null + + const list = rows ?? [] + const current = cloudId ? list.find((r) => r.id === cloudId) : undefined + + const save = async (asNew: boolean) => { + if (!uid || busy) return + const doc = useStore.getState().doc + const target = asNew ? undefined : current + setBusy(asNew ? 'Saving a copy…' : 'Saving…') + setError(null) + try { + const { id } = await saveToCloud(uid, doc, target ? { id: target.id, name: target.name } : { name: doc.meta.name }) + setCloudId(id) + markSaved() + setReload((n) => n + 1) + } catch (err) { + setError(message(err)) + } finally { + setBusy(null) + } + } + + const openDrawing = async (row: CloudDrawingMeta) => { + if (!uid) return + setConfirming(null) + setBusy('Opening…') + setError(null) + try { + const { doc } = await loadFromCloud(uid, row.id) + loadIntoStore(doc) + setCloudId(row.id) + onClose() + } catch (err) { + setError(message(err)) + } finally { + setBusy(null) + } + } + + const remove = async (row: CloudDrawingMeta) => { + if (!uid) return + setConfirming(null) + setBusy('Deleting…') + setError(null) + try { + await deleteDrawing(uid, row.id) + if (cloudId === row.id) setCloudId(null) + setReload((n) => n + 1) + } catch (err) { + setError(message(err)) + } finally { + setBusy(null) + } + } + + const commitRename = async () => { + if (!uid || !renaming) return + const { id, value } = renaming + setRenaming(null) + setBusy('Renaming…') + setError(null) + try { + await renameDrawing(uid, id, value) + // A drawing has one name. If this is the document currently open, carry + // the rename into it too — otherwise the next "Save to cloud" from the + // toolbar writes doc.meta.name straight back over the rename. + if (cloudId === id) useStore.getState().setMeta({ name: value.trim() || 'Untitled' }) + setReload((n) => n + 1) + } catch (err) { + setError(message(err)) + } finally { + setBusy(null) + } + } + + const idle = (style: CSSProperties): CSSProperties => (busy ? { ...style, opacity: 0.5, cursor: 'default' } : style) + + return ( + + {uid === undefined &&
Checking your account…
} + + {uid === null && ( +

+ Sign in to keep drawings in your IPD Studio account. They stay private to that account — nobody else can list + or open them. +

+ )} + + {uid && ( + <> +
+ + + + {busy ?? (dirty ? 'Unsaved changes' : '')} + +
+ + {error && ( +
+ {error} +
+ + +
+
+ )} + + {rows === null && !error &&
Loading your drawings…
} + + {rows !== null && rows.length === 0 && ( +
+ Nothing saved here yet. “Save current drawing” puts this drawing in your account, where it stays private + to your sign-in and opens on any device you use it from. +
+ )} + + {list.map((row) => { + const ren = renaming?.id === row.id ? renaming : null + const conf = confirming?.id === row.id ? confirming : null + return ( +
+
+
+ {ren ? ( + setRenaming({ id: row.id, value: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') void commitRename() + if (e.key === 'Escape') setRenaming(null) + }} + style={{ width: '100%', font: 'inherit', padding: '3px 6px', border: '1px solid #ccc', borderRadius: 4 }} + /> + ) : ( + {row.name} + )} + + {relTime(row.updatedAt)} · {formatBytes(row.sizeBytes)} · {row.sheetCount} sheet + {row.sheetCount === 1 ? '' : 's'} + {row.id === cloudId ? ' · open here' : ''} + +
+
+ {ren ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+ + {conf && ( +
+ + {conf.kind === 'open' + ? 'Opening this replaces the drawing on screen — your unsaved changes are lost.' + : `Delete “${row.name}” from your account? This cannot be undone.`} + + + +
+ )} +
+ ) + })} + + )} +
+ ) +} diff --git a/src/cloud/autosave.ts b/src/cloud/autosave.ts new file mode 100644 index 0000000..a0acde1 --- /dev/null +++ b/src/cloud/autosave.ts @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { create } from 'zustand' +import { useStore } from '../store/store' +import { useAuthStore } from '../auth/authStore' +import { saveToCloud } from './sync' + +export type CloudState = 'off' | 'saving' | 'saved' | 'error' + +interface CloudStatus { + state: CloudState + /** Set only when `state` is 'error' — shown to the user verbatim. */ + message: string | null + savedAt: number | null +} + +export const useCloudStatus = create()(() => ({ state: 'off', message: null, savedAt: null })) + +/** Long enough that a burst of dragging is one write, short enough that a + * browser closed mid-thought has already lost nothing. */ +const DEBOUNCE_MS = 2_000 + +let timer: ReturnType | null = null +let inflight = false +/** An edit that arrived while a write was in the air; replayed on completion + * so the cloud copy never settles one revision behind the screen. */ +let queued = false + +async function write(): Promise { + const user = useAuthStore.getState().user + const { doc, cloudId, setCloudId, markSaved } = useStore.getState() + if (!user) return + + inflight = true + useCloudStatus.setState({ state: 'saving', message: null }) + try { + const saved = await saveToCloud(user.uid, doc, { id: cloudId ?? undefined, name: doc.meta.name }) + setCloudId(saved.id) + // Only clear the dirty flag if nothing was edited while the write was in + // flight; otherwise the toolbar would claim "Saved" over unsaved edits. + if (!queued) markSaved() + useCloudStatus.setState({ state: 'saved', message: null, savedAt: Date.now() }) + } catch (err) { + useCloudStatus.setState({ + state: 'error', + message: err instanceof Error ? err.message : 'Could not save to your account.', + }) + } finally { + inflight = false + if (queued) { + queued = false + schedule() + } + } +} + +function schedule(): void { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + if (inflight) { + queued = true + return + } + void write() + }, DEBOUNCE_MS) +} + +/** + * Ctrl/Cmd+S and the toolbar Save button. Signed in, this writes to Firebase — + * it never downloads a file, because exporting is a separate deliberate act. + * Signed out there is no account to write to, so it falls back to the .pnid + * file save rather than leaving the shortcut dead. + */ +export async function saveNow(): Promise { + if (timer) { + clearTimeout(timer) + timer = null + } + if (!useAuthStore.getState().user) { + const { saveFile } = await import('../persist/file') + await saveFile() + return + } + if (inflight) { + queued = true + return + } + await write() +} + +/** + * Keeps the cloud copy current once a drawing has one. A drawing only gets a + * cloud record when the user asks for it (Ctrl+S, or Save to cloud) — silently + * uploading every anonymous doodle the moment someone signs in would break the + * local-first promise. + */ +export function startCloudAutosave(): () => void { + let prevDoc = useStore.getState().doc + + const unsubDoc = useStore.subscribe((s) => { + if (s.doc === prevDoc) return + prevDoc = s.doc + if (!s.cloudId) return + if (!useAuthStore.getState().user) return + schedule() + }) + + const unsubAuth = useAuthStore.subscribe((s) => { + if (s.user) return + // Signed out: stop pretending anything is syncing. + if (timer) { + clearTimeout(timer) + timer = null + } + queued = false + useCloudStatus.setState({ state: 'off', message: null, savedAt: null }) + }) + + return () => { + unsubDoc() + unsubAuth() + if (timer) clearTimeout(timer) + } +} diff --git a/src/cloud/firestore.ts b/src/cloud/firestore.ts new file mode 100644 index 0000000..db72da0 --- /dev/null +++ b/src/cloud/firestore.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { getFirestore, type Firestore } from 'firebase/firestore' +import { firebaseApp } from '../auth/firebase' + +/** Kept out of `auth/firebase.ts` deliberately: Firestore is the larger half of + * the SDK, and only the cloud-drawings feature needs it. Signing in should not + * make anyone download it. */ +let dbInstance: Firestore | null = null + +export function db(): Firestore { + if (!dbInstance) dbInstance = getFirestore(firebaseApp()) + return dbInstance +} diff --git a/src/cloud/sync.ts b/src/cloud/sync.ts new file mode 100644 index 0000000..7560f63 --- /dev/null +++ b/src/cloud/sync.ts @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { + addDoc, + collection, + deleteDoc, + doc as fsDoc, + getDoc, + getDocs, + orderBy, + query, + serverTimestamp, + setDoc, + Timestamp, + updateDoc, +} from 'firebase/firestore' +import type { ProjectDoc } from '../model/types' +import { db } from './firestore' +import { deserializeDoc, serializeDoc } from '../persist/file' + +/** Firestore caps a whole document near 1 MiB; the deployed rules cap the + * drawing string below that so the rest of the record always fits. */ +export const MAX_DOC_BYTES = 900_000 + +/** The rules reject a longer name, and a rules rejection reaches the user as + * an opaque permission error. */ +const NAME_MAX = 200 + +export interface CloudDrawingMeta { + id: string + name: string + sheetCount: number + sizeBytes: number + updatedAt: number + createdAt: number +} + +function drawings(uid: string) { + return collection(db(), 'users', uid, 'drawings') +} + +function cleanName(name: string): string { + return (name.trim() || 'Untitled').slice(0, NAME_MAX) +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} kB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +/** + * Everything a cloud record holds, with no Firestore call inside — pure, so the + * size rule can be tested without a network. The ProjectDoc goes up as ONE JSON + * string: Firestore rejects nested arrays and `Sheet.underlay.polylines` is an + * array of arrays. + */ +export function buildPayload(doc: ProjectDoc, name: string): { name: string; doc: string; sheetCount: number; sizeBytes: number } { + // The same JSON the file format uses, minus the indentation — a cloud record + // is parsed, never read by eye, and the whitespace was half the size cap. + const json = serializeDoc(doc, { pretty: false }) + return { + name: cleanName(name), + doc: json, + // byte length, not string length — a drawing full of non-ASCII tag text would under-measure + sizeBytes: new TextEncoder().encode(json).length, + sheetCount: doc.sheets.length, + } +} + +export function tooLargeMessage(sizeBytes: number): string { + return ( + `This drawing is ${formatBytes(sizeBytes)}, over the ${formatBytes(MAX_DOC_BYTES)} limit for a cloud drawing. ` + + 'A DXF underlay is almost always the reason — it carries every traced polyline of the original CAD file. ' + + 'Remove the underlay from its sheet and save again, or keep this one as a .pnid file on your computer.' + ) +} + +/** A record read straight back from the local cache still has `null` where the + * server timestamp will land. */ +function millis(value: unknown, fallback: number): number { + if (value instanceof Timestamp) return value.toMillis() + return typeof value === 'number' ? value : fallback +} + +function metaFrom(id: string, data: Record): CloudDrawingMeta { + const updatedAt = millis(data.updatedAt, Date.now()) + return { + id, + name: typeof data.name === 'string' ? data.name : 'Untitled', + sheetCount: typeof data.sheetCount === 'number' ? data.sheetCount : 0, + sizeBytes: typeof data.sizeBytes === 'number' ? data.sizeBytes : 0, + updatedAt, + createdAt: millis(data.createdAt, updatedAt), + } +} + +/** Firestore reports a rules rejection and a dead network with codes, never + * with anything a user can read — translate the few they can act on. */ +function friendly(err: unknown, what: string): Error { + const code = String((err as { code?: unknown } | null | undefined)?.code ?? '') + if (code.includes('unauthenticated')) return new Error(`${what} needs you to be signed in.`) + if (code.includes('permission-denied')) { + return new Error(`${what} was refused. Cloud drawings only open for the account that saved them — sign in again, then retry.`) + } + if (code.includes('unavailable') || code.includes('deadline-exceeded')) { + return new Error(`${what} could not reach the network. Check your connection and try again.`) + } + return err instanceof Error ? err : new Error(`${what} failed.`) +} + +export async function listDrawings(uid: string): Promise { + try { + const snap = await getDocs(query(drawings(uid), orderBy('updatedAt', 'desc'))) + return snap.docs.map((d) => metaFrom(d.id, d.data())) + } catch (err) { + throw friendly(err, 'Loading your cloud drawings') + } +} + +/** + * Writes the drawing under `opts.id`, or creates a record when there is none. + * `name` is written on every save because the rules demand a valid one even + * when an update recreates a record deleted from another device — so pass the + * cloud record's current name when updating, or the local document name takes + * over a rename made here. + */ +export async function saveToCloud(uid: string, doc: ProjectDoc, opts: { id?: string; name?: string }): Promise<{ id: string; updatedAt: number }> { + const payload = buildPayload(doc, opts.name ?? doc.meta.name) + if (payload.sizeBytes > MAX_DOC_BYTES) throw new Error(tooLargeMessage(payload.sizeBytes)) + try { + if (opts.id) { + // merge, so fields a newer build wrote survive a save from an older one + await setDoc(fsDoc(drawings(uid), opts.id), { ...payload, updatedAt: serverTimestamp() }, { merge: true }) + return { id: opts.id, updatedAt: Date.now() } + } + const ref = await addDoc(drawings(uid), { ...payload, createdAt: serverTimestamp(), updatedAt: serverTimestamp() }) + return { id: ref.id, updatedAt: Date.now() } + } catch (err) { + throw friendly(err, 'Saving to your account') + } +} + +export async function loadFromCloud(uid: string, id: string): Promise<{ doc: ProjectDoc; meta: CloudDrawingMeta }> { + let data: Record + try { + const snap = await getDoc(fsDoc(drawings(uid), id)) + if (!snap.exists()) throw new Error('That drawing is no longer in your account — it may have been deleted from another device.') + data = snap.data() + } catch (err) { + throw friendly(err, 'Opening this drawing') + } + const json = data.doc + if (typeof json !== 'string' || !json) throw new Error('That cloud record holds no drawing data.') + try { + return { doc: deserializeDoc(json), meta: metaFrom(id, data) } + } catch { + throw new Error('That drawing could not be read — it may have been saved by a newer version of IPD Studio.') + } +} + +export async function renameDrawing(uid: string, id: string, name: string): Promise { + try { + await updateDoc(fsDoc(drawings(uid), id), { name: cleanName(name), updatedAt: serverTimestamp() }) + } catch (err) { + throw friendly(err, 'Renaming this drawing') + } +} + +export async function deleteDrawing(uid: string, id: string): Promise { + try { + await deleteDoc(fsDoc(drawings(uid), id)) + } catch (err) { + throw friendly(err, 'Deleting this drawing') + } +} diff --git a/src/export/componentClass.ts b/src/export/componentClass.ts index b571e06..f5d683a 100644 --- a/src/export/componentClass.ts +++ b/src/export/componentClass.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + /** Pragmatic symbol-id -> Proteus/DEXPI ComponentClass mapping. */ const MAP: Record = { 'instr.bubble': 'ProcessInstrument', diff --git a/src/export/csv.ts b/src/export/csv.ts index cf2600f..856c719 100644 --- a/src/export/csv.ts +++ b/src/export/csv.ts @@ -1,7 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { PlantEdge, ProjectDoc } from '../model/types' import { isPortEnd } from '../model/types' import { expandLetters, formatTag } from '../isa/tag' import { getSymbol } from '../symbols/registry' +import { fieldValue, keyOfNode } from '../model/registry' import { useStore } from '../store/store' function csvField(v: string): string { @@ -21,8 +26,20 @@ function endName(doc: ProjectDoc, end: PlantEdge['source']): string { return isPortEnd(end) ? nodeName(doc, end.nodeId) : 'free end' } -export function instrumentIndexCsv(doc: ProjectDoc): string { - const lines = [row(['Tag', 'Description', 'Loop', 'Symbol', 'Sheet', 'Connected To', 'Notes'])] +/** One row of a generated report, carrying the id of the object it came from + * so a table can jump to it on the sheet. The CSV writers below and the Data + * workspace read the SAME rows — a report and the screen cannot disagree. */ +export interface ReportRow { + /** Node or edge id, for locateCell(). */ + id: string + sheetId: string + cells: string[] +} + +export const INSTRUMENT_INDEX_COLUMNS = ['Tag', 'Description', 'Loop', 'Symbol', 'Sheet', 'Connected To', 'Notes'] + +export function instrumentIndexRows(doc: ProjectDoc): ReportRow[] { + const rows: ReportRow[] = [] for (const sheet of doc.sheets) { for (const node of sheet.nodes) { if (!node.tag?.letters) continue @@ -33,8 +50,10 @@ export function instrumentIndexCsv(doc: ProjectDoc): string { return [] }) .join('; ') - lines.push( - row([ + rows.push({ + id: node.id, + sheetId: sheet.id, + cells: [ formatTag(node.tag, '-'), expandLetters(node.tag.letters), node.tag.loop, @@ -42,21 +61,31 @@ export function instrumentIndexCsv(doc: ProjectDoc): string { sheet.name, connected, node.label ?? '', - ]), - ) + ], + }) } } + return rows +} + +export function instrumentIndexCsv(doc: ProjectDoc): string { + const lines = [row(INSTRUMENT_INDEX_COLUMNS)] + for (const r of instrumentIndexRows(doc)) lines.push(row(r.cells)) return lines.join('\n') + '\n' } -export function lineListCsv(doc: ProjectDoc): string { - const lines = [row(['Line Number', 'Class', 'Size', 'Spec', 'Service', 'Seq', 'Sheet', 'From', 'To'])] +export const LINE_LIST_COLUMNS = ['Line Number', 'Class', 'Size', 'Spec', 'Service', 'Seq', 'Sheet', 'From', 'To'] + +export function lineListRows(doc: ProjectDoc): ReportRow[] { + const rows: ReportRow[] = [] for (const sheet of doc.sheets) { for (const edge of sheet.edges) { const ln = edge.lineNumber if (!ln || !(ln.size || ln.spec || ln.service || ln.seq)) continue - lines.push( - row([ + rows.push({ + id: edge.id, + sheetId: sheet.id, + cells: [ [ln.size, ln.spec, ln.service, ln.seq].filter(Boolean).join('-'), edge.lineClass, ln.size, @@ -66,21 +95,40 @@ export function lineListCsv(doc: ProjectDoc): string { sheet.name, endName(doc, edge.source), endName(doc, edge.target), - ]), - ) + ], + }) } } + return rows +} + +export function lineListCsv(doc: ProjectDoc): string { + const lines = [row(LINE_LIST_COLUMNS)] + for (const r of lineListRows(doc)) lines.push(row(r.cells)) return lines.join('\n') + '\n' } export function datasheetMatrixCsv(doc: ProjectDoc): string { const instruments = doc.sheets.flatMap((sh) => sh.nodes.filter((n) => n.kind === 'instrument')) - const keys = [...new Set(instruments.flatMap((n) => Object.keys(n.datasheet ?? {})))].sort() + // Columns come from both stores: the record where there is one, and the + // legacy per-node datasheet for anything not migrated yet. + const keys = [ + ...new Set( + instruments.flatMap((n) => [ + ...Object.keys(n.datasheet ?? {}), + ...Object.keys((keyOfNode(n) && doc.registry?.[keyOfNode(n)!]?.fields) || {}), + ]), + ), + ].sort() const lines = [row(['Tag', 'Description', ...keys])] for (const node of instruments) { const tagText = node.tag ? formatTag(node.tag, '-') : '' lines.push( - row([tagText, node.tag ? expandLetters(node.tag.letters) : '', ...keys.map((k) => node.datasheet?.[k] ?? '')]), + row([ + tagText, + node.tag ? expandLetters(node.tag.letters) : '', + ...keys.map((k) => fieldValue(doc.registry, node, k)), + ]), ) } return lines.join('\n') + '\n' diff --git a/src/export/datasheetPdf.ts b/src/export/datasheetPdf.ts index 0d3029c..5593207 100644 --- a/src/export/datasheetPdf.ts +++ b/src/export/datasheetPdf.ts @@ -1,4 +1,9 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { DATASHEET_SECTIONS, fieldsFor } from '../model/datasheet' +import { fieldValue } from '../model/registry' import type { PlantNode, ProjectDoc } from '../model/types' import { expandLetters, formatTag } from '../isa/tag' @@ -19,7 +24,7 @@ export function printDatasheet(doc: ProjectDoc, node: PlantNode): void { fields .map( (f) => - `${esc(f.label)}${esc(node.datasheet?.[f.key] ?? '')}`, + `${esc(f.label)}${esc(fieldValue(doc.registry, node, f.key))}`, ) .join('') ) diff --git a/src/export/dexpi.ts b/src/export/dexpi.ts index 0960f91..46fc294 100644 --- a/src/export/dexpi.ts +++ b/src/export/dexpi.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { PlantEdge, PlantNode, ProjectDoc, Sheet } from '../model/types' import { isPortEnd } from '../model/types' import { sheetPx } from '../model/doc' diff --git a/src/export/dxf.ts b/src/export/dxf.ts index a094074..46deffc 100644 --- a/src/export/dxf.ts +++ b/src/export/dxf.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { PlantEdge, PlantNode, ProjectDoc } from '../model/types' import { isPortEnd } from '../model/types' import { sheetPx } from '../model/doc' diff --git a/src/export/loopDiagram.ts b/src/export/loopDiagram.ts index ca393d3..c42d40f 100644 --- a/src/export/loopDiagram.ts +++ b/src/export/loopDiagram.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { ProjectDoc, Tag } from '../model/types' import { deriveLoops } from '../store/selectors' import { formatTag } from '../isa/tag' diff --git a/src/export/png.ts b/src/export/png.ts index 435fd38..3b3861e 100644 --- a/src/export/png.ts +++ b/src/export/png.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { sheetPx } from '../model/doc' import { exportSvg } from './svg' import { activeSheet, useStore } from '../store/store' diff --git a/src/export/printAll.ts b/src/export/printAll.ts index b2ef82c..a89d1c2 100644 --- a/src/export/printAll.ts +++ b/src/export/printAll.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { SHEET_SIZES_MM } from '../model/doc' import type { ProjectDoc } from '../model/types' import { exportSvg } from './svg' diff --git a/src/export/printPdf.ts b/src/export/printPdf.ts index f879c78..48069e9 100644 --- a/src/export/printPdf.ts +++ b/src/export/printPdf.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { SHEET_SIZES_MM } from '../model/doc' import { exportSvg } from './svg' import { activeSheet, useStore } from '../store/store' diff --git a/src/export/svg.ts b/src/export/svg.ts index 04ecc7d..d01730c 100644 --- a/src/export/svg.ts +++ b/src/export/svg.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { SHEET_SIZES_MM, sheetPx } from '../model/doc' import type { ProjectDoc, Sheet } from '../model/types' import { activeSheet } from '../store/store' @@ -58,6 +62,7 @@ export function exportSvg(doc: ProjectDoc, sheet: Sheet): string { '.joint-tools', '.joint-highlighter-layer', '.pid-underlay', + '.pid-sheet', '[joint-selector="portBody"]', '[joint-selector="portDot"]', '[joint-selector="hit"]', diff --git a/src/hmi/AlarmBanner.tsx b/src/hmi/AlarmBanner.tsx index 2e3fbfc..325a004 100644 --- a/src/hmi/AlarmBanner.tsx +++ b/src/hmi/AlarmBanner.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useState } from 'react' import { useSimStore } from './simStore' import type { AlarmPriority, AlarmRecord, JournalEntry } from './sim/alarms' diff --git a/src/hmi/Faceplate.tsx b/src/hmi/Faceplate.tsx index 011f97e..a5f0d0f 100644 --- a/src/hmi/Faceplate.tsx +++ b/src/hmi/Faceplate.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useEffect, useRef, useState } from 'react' import { useSimStore } from './simStore' import type { HmiWidget } from './model' diff --git a/src/hmi/HmiCanvas.tsx b/src/hmi/HmiCanvas.tsx index 99c49e8..a4fb158 100644 --- a/src/hmi/HmiCanvas.tsx +++ b/src/hmi/HmiCanvas.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { memo, useEffect, useRef, useState } from 'react' import type { ThemeTokens } from './theme' import type { TrendData } from './widgets/shared' diff --git a/src/hmi/HmiPalette.tsx b/src/hmi/HmiPalette.tsx index a4da2b3..b062ce8 100644 --- a/src/hmi/HmiPalette.tsx +++ b/src/hmi/HmiPalette.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiWidget, WidgetType } from './model' import { WIDGET_DEFAULT_SIZE } from './model' import { THEMES } from './theme' diff --git a/src/hmi/HmiPropertyPanel.tsx b/src/hmi/HmiPropertyPanel.tsx index 2bf01c6..17dfeaa 100644 --- a/src/hmi/HmiPropertyPanel.tsx +++ b/src/hmi/HmiPropertyPanel.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useStore, activeHmiScreen, pauseHistory, resumeHistory } from '../store/store' import type { HmiWidget } from './model' import type { AlignMode } from './align' diff --git a/src/hmi/HmiToolbar.tsx b/src/hmi/HmiToolbar.tsx index e6d8e74..5e888b0 100644 --- a/src/hmi/HmiToolbar.tsx +++ b/src/hmi/HmiToolbar.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useState } from 'react' import { useStore, activeHmiScreen } from '../store/store' import { useSimStore } from './simStore' diff --git a/src/hmi/HmiWorkspace.tsx b/src/hmi/HmiWorkspace.tsx index 685841d..9325458 100644 --- a/src/hmi/HmiWorkspace.tsx +++ b/src/hmi/HmiWorkspace.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import './hmi.css' import { useEffect, useMemo, useRef, useState } from 'react' import { THEMES } from './theme' @@ -14,6 +18,7 @@ import { copySelection, pastePayload } from './clipboard' import { HMI_WORLD } from './model' import { worstAlarmByScreen } from './navAlarms' import Modal from '../panels/Modal' +import { VersionChip } from '../panels/VersionNote' import Faceplate from './Faceplate' import AlarmBanner from './AlarmBanner' @@ -133,7 +138,7 @@ export default function HmiWorkspace({ onExit }: { onExit(): void }) { const k = e.key.toLowerCase() if (k === 'z') { e.preventDefault(); undoRedo(e.shiftKey ? 'redo' : 'undo') } else if (k === 'y') { e.preventDefault(); undoRedo('redo') } - else if (k === 's') { e.preventDefault(); void import('../persist/file').then((m) => m.saveFile()) } + else if (k === 's') { e.preventDefault(); void import('../cloud/autosave').then((m) => m.saveNow()) } else if (k === 'c' || k === 'x' || k === 'v') { const c = clipCtx.current if (c.mode !== 'edit' || !c.screen) return @@ -259,6 +264,7 @@ function StatusBar({ screenName, selection, notice }: { screenName?: string; sel return (
HMI workspace + {screenName && · {screenName}} {notice && {notice}} {mode === 'run' ? ( diff --git a/src/hmi/ScreenTabs.tsx b/src/hmi/ScreenTabs.tsx index c4e7fd5..be46489 100644 --- a/src/hmi/ScreenTabs.tsx +++ b/src/hmi/ScreenTabs.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useState } from 'react' import { useStore } from '../store/store' import { useSimStore } from './simStore' diff --git a/src/hmi/TagPicker.tsx b/src/hmi/TagPicker.tsx index 0f75854..0626fb0 100644 --- a/src/hmi/TagPicker.tsx +++ b/src/hmi/TagPicker.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useMemo, useState } from 'react' import { pauseHistory, resumeHistory, useStore } from '../store/store' import type { HmiRole } from './tagIndex' diff --git a/src/hmi/align.ts b/src/hmi/align.ts index 4b7aaf3..6f2ab6b 100644 --- a/src/hmi/align.ts +++ b/src/hmi/align.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiWidget } from './model' export type AlignMode = 'left' | 'centerX' | 'right' | 'top' | 'middleY' | 'bottom' diff --git a/src/hmi/clipboard.ts b/src/hmi/clipboard.ts index 51f7055..9bd9f3c 100644 --- a/src/hmi/clipboard.ts +++ b/src/hmi/clipboard.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiPipe, HmiScreen, HmiWidget } from './model' import { snap8 } from './editGeometry' diff --git a/src/hmi/editGeometry.ts b/src/hmi/editGeometry.ts index 5457ed4..32123bd 100644 --- a/src/hmi/editGeometry.ts +++ b/src/hmi/editGeometry.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiPipe, HmiScreen, HmiWidget } from './model' export interface Rect { x: number; y: number; w: number; h: number } diff --git a/src/hmi/hmi.css b/src/hmi/hmi.css index fbf9e21..f58d7c6 100644 --- a/src/hmi/hmi.css +++ b/src/hmi/hmi.css @@ -1,3 +1,7 @@ +/* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; + * commercial use requires a paid license (see COMMERCIAL-LICENSE.md). */ + .hmi { height: 100%; display: grid; diff --git a/src/hmi/importFromPid.ts b/src/hmi/importFromPid.ts index c90ea9a..80a9737 100644 --- a/src/hmi/importFromPid.ts +++ b/src/hmi/importFromPid.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { ulid } from 'ulid' import type { PlantEdge, PlantNode, ProjectDoc, Sheet } from '../model/types' import { isPortEnd } from '../model/types' diff --git a/src/hmi/model.ts b/src/hmi/model.ts index 7810d0a..9feff4c 100644 --- a/src/hmi/model.ts +++ b/src/hmi/model.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { ulid } from 'ulid' export type HmiTheme = 'classic' | 'hp' diff --git a/src/hmi/navAlarms.ts b/src/hmi/navAlarms.ts index f83fa46..8084728 100644 --- a/src/hmi/navAlarms.ts +++ b/src/hmi/navAlarms.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiScreen } from './model' import type { AlarmPriority, AlarmRecord } from './sim/alarms' diff --git a/src/hmi/overview.ts b/src/hmi/overview.ts index 761617d..504f61d 100644 --- a/src/hmi/overview.ts +++ b/src/hmi/overview.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { ulid } from 'ulid' import type { HmiScreen, HmiWidget } from './model' import { HMI_WORLD } from './model' diff --git a/src/hmi/routePipes.ts b/src/hmi/routePipes.ts index 98ec9e2..1e62b13 100644 --- a/src/hmi/routePipes.ts +++ b/src/hmi/routePipes.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + /** * Orthogonal pipe router for P&ID -> HMI import. * diff --git a/src/hmi/sim/alarms.ts b/src/hmi/sim/alarms.ts index ac1e81f..8a41d47 100644 --- a/src/hmi/sim/alarms.ts +++ b/src/hmi/sim/alarms.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { TagDef } from './tags' import type { Tags } from './engine' diff --git a/src/hmi/sim/commands.ts b/src/hmi/sim/commands.ts index 9a4d77a..15e95fe 100644 --- a/src/hmi/sim/commands.ts +++ b/src/hmi/sim/commands.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { CommandEvent, JournalEntry } from './alarms' const fmt = (v: number) => (Math.round(v * 10) / 10).toString() diff --git a/src/hmi/sim/engine.ts b/src/hmi/sim/engine.ts index 04ca411..f775066 100644 --- a/src/hmi/sim/engine.ts +++ b/src/hmi/sim/engine.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiScreen } from '../model' import type { TagDef } from './tags' import { buildTagDefs } from './tags' diff --git a/src/hmi/sim/network.ts b/src/hmi/sim/network.ts index d6de5a7..4bf9388 100644 --- a/src/hmi/sim/network.ts +++ b/src/hmi/sim/network.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiPipe, HmiScreen, HmiWidget } from '../model' export type EndRef = { kind: 'source' } | { kind: 'sink' } | { kind: 'tank'; tag: string } diff --git a/src/hmi/sim/noise.ts b/src/hmi/sim/noise.ts index 137eabc..14faafd 100644 --- a/src/hmi/sim/noise.ts +++ b/src/hmi/sim/noise.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + /** Deterministic LCG (numerical recipes constants) so sim tests can pin a seed. */ export function makeRng(seed: number): () => number { let s = seed >>> 0 diff --git a/src/hmi/sim/tags.ts b/src/hmi/sim/tags.ts index 684c00f..aa44113 100644 --- a/src/hmi/sim/tags.ts +++ b/src/hmi/sim/tags.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiScreen, HmiWidget } from '../model' export type TagKind = 'tank' | 'motor' | 'valve' | 'valveOnOff' | 'display' | 'controller' diff --git a/src/hmi/simStore.ts b/src/hmi/simStore.ts index f04b1ca..46e5cf4 100644 --- a/src/hmi/simStore.ts +++ b/src/hmi/simStore.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { create } from 'zustand' import { useEffect } from 'react' import type { HmiScreen } from './model' diff --git a/src/hmi/tagIndex.ts b/src/hmi/tagIndex.ts index be617ae..a3225a1 100644 --- a/src/hmi/tagIndex.ts +++ b/src/hmi/tagIndex.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { PlantNode, ProjectDoc } from '../model/types' import { expandLetters, formatTag } from '../isa/tag' import { getSymbol } from '../symbols/registry' diff --git a/src/hmi/theme.ts b/src/hmi/theme.ts index 10d20ec..9e758b8 100644 --- a/src/hmi/theme.ts +++ b/src/hmi/theme.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiTheme } from './model' export interface ThemeTokens { diff --git a/src/hmi/view.ts b/src/hmi/view.ts index a37f366..c4ebc28 100644 --- a/src/hmi/view.ts +++ b/src/hmi/view.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { HMI_WORLD } from './model' /** Canvas view: world-space center + zoom factor. null = fit (whole world). */ diff --git a/src/hmi/widgets/bar.tsx b/src/hmi/widgets/bar.tsx index 5170173..23c53a2 100644 --- a/src/hmi/widgets/bar.tsx +++ b/src/hmi/widgets/bar.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' import { fmt, num } from './shared' diff --git a/src/hmi/widgets/button.tsx b/src/hmi/widgets/button.tsx index 1b9eaab..2dc6fc6 100644 --- a/src/hmi/widgets/button.tsx +++ b/src/hmi/widgets/button.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' /** Pure visual; the canvas wires pointer events in run mode. */ diff --git a/src/hmi/widgets/display.tsx b/src/hmi/widgets/display.tsx index 41b8026..b0632e0 100644 --- a/src/hmi/widgets/display.tsx +++ b/src/hmi/widgets/display.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' import { fmt, num } from './shared' diff --git a/src/hmi/widgets/equip.tsx b/src/hmi/widgets/equip.tsx index 049c34f..5256045 100644 --- a/src/hmi/widgets/equip.tsx +++ b/src/hmi/widgets/equip.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { getSymbol } from '../../symbols/registry' import type { WidgetView } from './shared' diff --git a/src/hmi/widgets/gauge.tsx b/src/hmi/widgets/gauge.tsx index 3321f85..8692ed3 100644 --- a/src/hmi/widgets/gauge.tsx +++ b/src/hmi/widgets/gauge.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' import { fmt, num } from './shared' diff --git a/src/hmi/widgets/index.tsx b/src/hmi/widgets/index.tsx index 254e981..0252851 100644 --- a/src/hmi/widgets/index.tsx +++ b/src/hmi/widgets/index.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import '../../symbols/lib/index' import type { WidgetView } from './shared' import Tank from './tank' diff --git a/src/hmi/widgets/label.tsx b/src/hmi/widgets/label.tsx index bab9b97..80d096e 100644 --- a/src/hmi/widgets/label.tsx +++ b/src/hmi/widgets/label.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' export default function LabelText({ widget, theme }: WidgetView) { diff --git a/src/hmi/widgets/lamp.tsx b/src/hmi/widgets/lamp.tsx index 2fe62eb..391488a 100644 --- a/src/hmi/widgets/lamp.tsx +++ b/src/hmi/widgets/lamp.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' export default function Lamp({ widget, theme, sim }: WidgetView) { diff --git a/src/hmi/widgets/nav.tsx b/src/hmi/widgets/nav.tsx index 2e1d02b..c618d98 100644 --- a/src/hmi/widgets/nav.tsx +++ b/src/hmi/widgets/nav.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' /** Screen navigation button: clicking it in run mode jumps to props.screen. diff --git a/src/hmi/widgets/panel.tsx b/src/hmi/widgets/panel.tsx index 3b8fe29..601cfee 100644 --- a/src/hmi/widgets/panel.tsx +++ b/src/hmi/widgets/panel.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' /** Grouping frame: professional screens box related equipment into sections. diff --git a/src/hmi/widgets/pump.tsx b/src/hmi/widgets/pump.tsx index da180ce..8fd2719 100644 --- a/src/hmi/widgets/pump.tsx +++ b/src/hmi/widgets/pump.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' export default function Pump({ widget, theme, sim }: WidgetView) { diff --git a/src/hmi/widgets/shared.ts b/src/hmi/widgets/shared.ts index 72d722d..2248640 100644 --- a/src/hmi/widgets/shared.ts +++ b/src/hmi/widgets/shared.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { HmiWidget } from '../model' import type { ThemeTokens } from '../theme' diff --git a/src/hmi/widgets/switchw.tsx b/src/hmi/widgets/switchw.tsx index 6e8c3e8..f30cc2f 100644 --- a/src/hmi/widgets/switchw.tsx +++ b/src/hmi/widgets/switchw.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' export default function ToggleSwitch({ widget, theme, sim }: WidgetView) { diff --git a/src/hmi/widgets/symbol.tsx b/src/hmi/widgets/symbol.tsx index 830e44d..8d0a24a 100644 --- a/src/hmi/widgets/symbol.tsx +++ b/src/hmi/widgets/symbol.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { getSymbol } from '../../symbols/registry' import type { WidgetView } from './shared' diff --git a/src/hmi/widgets/tank.tsx b/src/hmi/widgets/tank.tsx index 2e99345..0bc1888 100644 --- a/src/hmi/widgets/tank.tsx +++ b/src/hmi/widgets/tank.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' import { fmt } from './shared' diff --git a/src/hmi/widgets/trend.tsx b/src/hmi/widgets/trend.tsx index 9ecfa31..64c3b0b 100644 --- a/src/hmi/widgets/trend.tsx +++ b/src/hmi/widgets/trend.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useState } from 'react' import type { WidgetView } from './shared' import { fmt, num } from './shared' diff --git a/src/hmi/widgets/valve.tsx b/src/hmi/widgets/valve.tsx index 125ca8e..3410e20 100644 --- a/src/hmi/widgets/valve.tsx +++ b/src/hmi/widgets/valve.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { WidgetView } from './shared' import { fmt } from './shared' diff --git a/src/home/Home.tsx b/src/home/Home.tsx new file mode 100644 index 0000000..25d2083 --- /dev/null +++ b/src/home/Home.tsx @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { VersionBanner } from '../panels/VersionNote' +import UpdateToast from '../panels/UpdateToast' +import './home.css' + +const REPO = 'https://github.com/Coldbari/IPD-Studio' +const DEMO = 'https://coldbari.github.io/IPD-Studio/' +const LICENCE = `${REPO}/blob/main/COMMERCIAL-LICENSE.md` +const DOCS = `${REPO}/tree/main/docs` +const EMAIL = 'praharshchamp610@gmail.com' + +export default function Home({ + onSignIn, + onOpenEditor, +}: { + onSignIn(): void + onOpenEditor(): void +}) { + return ( +
+ Skip to content + + + +
+ IPD Studio + +
+ + +
+
+ +
+
+

A P&ID is a database that happens to look like a drawing.

+

+ Draw with real ISA-5.1 symbols and the tags mean something. Type FT-101 and + IPD Studio knows it is a flow transmitter on loop 101, checks it against the letter + tables, and builds your instrument index and line list from the model instead of asking + you to type them twice. +

+

+ + Watch the demo +

+

Runs in the browser. Nothing to install — create a free account and start drawing.

+
+ +
+ The IPD Studio editor: symbol palette on the left, a tagged P&ID on an A3 sheet, properties on the right +
+ +
+

What it does

+
+
+

Tags that are read, not just drawn

+

+ Every tag is parsed against the ISA-5.1 letter tables. Loop numbers auto-assign per + type, duplicates are flagged as you work, and control loops fall out of the tags on + their own. +

+
+
+

Deliverables from the model

+

+ Instrument index, line list, datasheets and loop diagrams are generated from the + document, so they cannot drift from the drawing. Export DEXPI, DXF, PDF or CSV. +

+
+
+

Costs while you draw

+

+ Components carry budgetary prices, so the estimate moves as the drawing does, with + installed-cost factors and a warning when you pass the budget you set. +

+
+
+
+ +
+
+

Then run the plant you drew

+

+ One click turns the P&ID into an operator screen. PI loops wire themselves from + your tags, ISA-18.2 alarms carry priorities and shelving, trends plot against a real + time axis, and you can trip a pump or stick a valve to see what the trainee does. +

+
+
+ The same plant as an operator screen, with tanks, pumps, valves and live displays +
+
+ +
+

Licence

+

+ IPD Studio is source-available under PolyForm Noncommercial 1.0.0 — not open source. You + can read the code, learn from it and verify what it does. Commercial use is a paid + licence, and that licence is what keeps it free for everyone in the first column. +

+
+
+

Free, no permission needed

+
    +
  • Personal use, study and hobby projects
  • +
  • Students and coursework
  • +
  • Universities, schools and labs
  • +
  • Charities and public research bodies
  • +
  • Government institutions
  • +
+
+
+

Needs a paid licence

+
    +
  • Use by or for a for-profit company, including internally
  • +
  • P&IDs drawn for paid client or consulting work
  • +
  • Paid operator training delivered with HMI Studio
  • +
  • Embedding, hosting or reselling IPD Studio
  • +
+

+ Read the commercial licence or + email {EMAIL}. +

+
+
+
+ +
+

Start drawing

+

Free for personal, academic, nonprofit and government use. Your drawings stay private to your account.

+ +
+
+ + {/* A drawing sheet ends in a title block; so does this page. */} + + +
+ ) +} diff --git a/src/home/home.css b/src/home/home.css new file mode 100644 index 0000000..7fad3de --- /dev/null +++ b/src/home/home.css @@ -0,0 +1,241 @@ +/* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; + * commercial use requires a paid license (see COMMERCIAL-LICENSE.md). */ + +/* A P&ID is black linework on white paper, and the editor is a light app, so + * the front door is paper too: ink on near-white, hairline rules for the sheet + * border, and monospace kept for the things a real drawing sets in monospace — + * tags, revisions, the title block. Everything is scoped under .home so none + * of it can reach the editor's stylesheet. */ + +.home { + --paper: #fcfcfb; + --paper-2: #f4f5f4; + --ink: #14181d; + --ink-2: #454f59; + --ink-3: #6b7681; + --rule: #d9dcde; + --accent: #0e6e7a; + + --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + + background: var(--paper); + color: var(--ink); + font: 400 17px/1.65 var(--sans); + -webkit-font-smoothing: antialiased; + min-height: 100%; +} + +.home *, .home *::before, .home *::after { box-sizing: border-box; } +.home h1, .home h2, .home h3, .home p, .home ul, .home dl { margin: 0; } +.home a { color: var(--accent); text-underline-offset: 3px; } +.home a:hover { text-decoration: underline; } + +.home .skip { + position: absolute; left: -9999px; + background: var(--ink); color: var(--paper); padding: 10px 14px; +} +.home .skip:focus { left: 12px; top: 12px; z-index: 10; } +.home :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } + +/* ---- shared ---- */ + +.home .btn { + display: inline-block; + padding: 11px 20px; + border: 1px solid var(--ink); + border-radius: 3px; + background: var(--ink); + color: var(--paper); + font: 500 15px/1 var(--sans); + cursor: pointer; + text-decoration: none; +} +.home .btn:hover { background: #000; border-color: #000; text-decoration: none; } +.home .btn-lg { padding: 14px 24px; font-size: 16px; } +.home .btn-quiet { background: transparent; color: var(--ink); border-color: var(--rule); } +.home .btn-quiet:hover { background: var(--paper-2); border-color: var(--ink-3); color: var(--ink); } + +.home .link { + border: 0; background: none; cursor: pointer; + font: 500 15px/1 var(--sans); color: var(--ink-2); padding: 8px 4px; +} +.home .link:hover { color: var(--ink); text-decoration: underline; } + +.home code { + font: 500 0.92em/1 var(--mono); + background: var(--paper-2); + border: 1px solid var(--rule); + border-radius: 2px; + padding: 2px 5px; +} + +.home main > section { + max-width: 1080px; + margin: 0 auto; + padding: 76px 28px; + border-top: 1px solid var(--rule); +} +.home main > section:first-child { border-top: 0; } + +.home h2 { + font: 600 15px/1.2 var(--mono); + letter-spacing: .06em; + text-transform: uppercase; + color: var(--ink-3); + margin-bottom: 32px; +} +.home h3 { font: 600 19px/1.35 var(--sans); margin-bottom: 10px; letter-spacing: -0.01em; } + +/* ---- in-development strip ---- */ + +.home .version-banner { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; + /* full-bleed background, but the text lines up with the 1080px column the + nav and every section below it use */ + padding: 9px max(28px, calc((100% - 1080px) / 2 + 28px)); + background: var(--paper-2); + border-bottom: 1px solid var(--rule); + font-size: 13.5px; color: var(--ink-2); +} +.home .version-banner strong { + font: 600 11px/1.4 var(--mono); + letter-spacing: .08em; text-transform: uppercase; color: var(--ink); +} +.home .version-banner span { flex: 1 1 auto; min-width: 0; } +.home .version-banner button { + border: 1px solid var(--rule); background: var(--paper); color: var(--ink); + border-radius: 3px; padding: 5px 12px; font: 500 13px/1 var(--sans); cursor: pointer; +} +.home .version-banner button:hover { border-color: var(--ink-3); } + +/* ---- top bar ---- */ + +.home .bar { + display: flex; align-items: center; gap: 28px; + max-width: 1080px; margin: 0 auto; padding: 20px 28px; + border-bottom: 1px solid var(--rule); +} +.home .wordmark { font: 600 17px/1 var(--sans); letter-spacing: -0.02em; } +.home .bar-nav { display: flex; gap: 22px; flex: 1; } +.home .bar-nav a { + color: var(--ink-2); text-decoration: none; font-size: 15px; +} +.home .bar-nav a:hover { color: var(--ink); } +.home .bar-actions { display: flex; align-items: center; gap: 12px; } + +/* ---- hero ---- */ + +.home .hero { padding-top: 84px; padding-bottom: 44px; } +.home .hero h1 { + max-width: 17ch; + font: 600 clamp(38px, 6.2vw, 62px)/1.08 var(--sans); + letter-spacing: -0.032em; +} +.home .hero p { + max-width: 62ch; margin-top: 26px; color: var(--ink-2); +} +.home .actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 34px; } +.home .note { font-size: 14.5px; color: var(--ink-3); margin-top: 18px; } + +/* ---- screenshots ---- */ + +.home .shot { max-width: 1080px; margin: 0 auto; padding: 0 28px 76px; } +.home .shot img { + display: block; width: 100%; height: auto; + border: 1px solid var(--rule); border-radius: 3px; +} +.home .shot-inline { padding: 0; } + +/* ---- three columns ---- */ + +.home .cols { + display: grid; gap: 44px; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +} +.home .cols p { color: var(--ink-2); font-size: 16px; } + +/* ---- hmi ---- */ + +.home .hmi { display: grid; gap: 44px; grid-template-columns: 1fr 1fr; align-items: center; } +.home .hmi h2 { margin-bottom: 18px; } +.home .hmi-copy p { color: var(--ink-2); max-width: 46ch; } + +/* ---- licence ---- */ + +.home .licence .lede { max-width: 68ch; color: var(--ink-2); margin-bottom: 40px; } +.home .licence .cols { gap: 56px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } +.home .licence ul { list-style: none; padding: 0; } +.home .licence li { + padding: 9px 0 9px 20px; border-bottom: 1px solid var(--rule); + font-size: 16px; color: var(--ink-2); position: relative; +} +.home .licence li::before { + content: ""; position: absolute; left: 0; top: 19px; + width: 9px; border-top: 1px solid var(--ink-3); +} +.home .licence .cols p { margin-top: 18px; font-size: 15.5px; } + +/* ---- closing ---- */ + +.home .last { text-align: center; } +.home .last h2 { margin-bottom: 14px; } +.home .last p { color: var(--ink-2); margin-bottom: 26px; } + +/* ---- title block ---- */ + +.home .block { + max-width: 1080px; margin: 0 auto 64px; padding: 0 28px; +} +.home .block dl { + border: 1px solid var(--ink-3); + display: grid; grid-template-columns: repeat(3, 1fr); +} +.home .block dl > div { + padding: 12px 16px; + border-right: 1px solid var(--rule); + border-bottom: 1px solid var(--rule); + min-width: 0; +} +.home .block dl > div:nth-child(3n) { border-right: 0; } +.home .block dl > div:nth-last-child(-n + 3) { border-bottom: 0; } +.home .block dt { + font: 500 11px/1.4 var(--mono); + letter-spacing: .09em; text-transform: uppercase; color: var(--ink-3); + margin-bottom: 3px; +} +.home .block dd { + margin: 0; font: 400 14px/1.4 var(--mono); color: var(--ink); + overflow-wrap: break-word; +} + +/* ---- responsive ---- */ + +@media (max-width: 860px) { + .home .hmi { grid-template-columns: 1fr; } + .home .block dl { grid-template-columns: repeat(2, 1fr); } + .home .block dl > div:nth-child(3n) { border-right: 1px solid var(--rule); } + .home .block dl > div:nth-child(2n) { border-right: 0; } + .home .block dl > div:nth-last-child(-n + 3) { border-bottom: 1px solid var(--rule); } + .home .block dl > div:nth-last-child(-n + 2) { border-bottom: 0; } +} + +@media (max-width: 700px) { + .home { font-size: 16px; } + .home .version-banner { padding: 9px 20px; font-size: 12.5px; } + .home .bar { flex-wrap: wrap; gap: 14px; padding: 16px 20px; } + .home .bar-nav { order: 3; flex-basis: 100%; gap: 16px; overflow-x: auto; padding-bottom: 2px; } + .home .bar-actions { margin-left: auto; } + .home main > section { padding: 52px 20px; } + .home .hero { padding-top: 56px; } + .home .shot { padding: 0 20px 52px; } + .home .block { padding: 0 20px; } + .home .block dl { grid-template-columns: 1fr; } + .home .block dl > div { border-right: 0 !important; border-bottom: 1px solid var(--rule) !important; } + .home .block dl > div:last-child { border-bottom: 0 !important; } +} + +@media (prefers-reduced-motion: reduce) { + .home * { animation: none !important; transition: none !important; } +} diff --git a/src/import/dexpi.ts b/src/import/dexpi.ts index 274dfd0..ea3274a 100644 --- a/src/import/dexpi.ts +++ b/src/import/dexpi.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { XMLParser, XMLValidator } from 'fast-xml-parser' import { ulid } from 'ulid' import type { LineClass, PlantEdge, PlantNode, Sheet } from '../model/types' diff --git a/src/import/dxfUnderlay.ts b/src/import/dxfUnderlay.ts index d3f8755..43d0bee 100644 --- a/src/import/dxfUnderlay.ts +++ b/src/import/dxfUnderlay.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import DxfParser from 'dxf-parser' export interface UnderlayResult { diff --git a/src/import/svgSymbol.ts b/src/import/svgSymbol.ts index f16e153..e1d24cd 100644 --- a/src/import/svgSymbol.ts +++ b/src/import/svgSymbol.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + export class SvgImportError extends Error {} const ALLOWED_TAGS = new Set(['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'g', 'text']) diff --git a/src/isa/autonumber.ts b/src/isa/autonumber.ts index 69320d8..035e5a8 100644 --- a/src/isa/autonumber.ts +++ b/src/isa/autonumber.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { PlantNode, ProjectDoc, Tag } from '../model/types' import { isPortEnd } from '../model/types' diff --git a/src/isa/letters.ts b/src/isa/letters.ts index 890e23f..77c582a 100644 --- a/src/isa/letters.ts +++ b/src/isa/letters.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + /** * ISA-5.1-style identification letter tables. * Authored independently from public-domain engineering knowledge of the diff --git a/src/isa/tag.ts b/src/isa/tag.ts index e10bbd1..064f573 100644 --- a/src/isa/tag.ts +++ b/src/isa/tag.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { Tag } from '../model/types' import { FIRST_LETTERS, diff --git a/src/main.tsx b/src/main.tsx index b5ca329..945f006 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,39 +1,59 @@ -import { StrictMode } from 'react' +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { lazy, StrictMode, Suspense } from 'react' import { createRoot } from 'react-dom/client' -import App from './App' import './app.css' -import { useStore } from './store/store' -import { canvasRef } from './canvas/paperSetup' -import { startAutosave, restoreAutosave } from './persist/autosave' +import { navigate, useRoute } from './routes' -// Dev/e2e hook: drive the store from the console or Playwright. -if (import.meta.env.DEV) { - ;(window as unknown as Record).__pid = { useStore, canvasRef } +// Split at the route boundary: the homepage must not ship JointJS, the symbol +// catalog and the HMI simulator just to render a headline. +const EditorRoot = lazy(() => import('./EditorRoot')) +const Home = lazy(() => import('./home/Home')) + +function Loading({ label }: { label: string }) { + return
{label}
} -startAutosave() -void restoreAutosave().then((saved) => { - if (saved && window.confirm(`Restore autosaved drawing “${saved.meta.name}”?`)) { - useStore.getState().loadIntoStore(saved) +function Root() { + const route = useRoute() + + if (route === 'app') { + return ( + }> + + + ) } -}) -// Installed-PWA file handling: double-clicked .pnid files arrive here. -interface LaunchQueueWindow extends Window { - launchQueue?: { setConsumer(cb: (params: { files: { getFile(): Promise }[] }) => void): void } + // Signing in and opening the editor are the same journey now: the editor + // route puts the sign-in screen in front of anyone without an account. + return ( + }> + navigate('/app')} onOpenEditor={() => navigate('/app')} /> + + ) +} + +// Dev/e2e hook: drive the store from the console or Playwright. Awaited at +// module top level on purpose — the editor is a lazy chunk now, so an effect +// or a floating promise would set this after `load` fires, and the e2e specs +// evaluate `__pid` the instant page.goto() resolves. `import.meta.env.DEV` is +// substituted at build time, so this whole branch is dropped from production +// and neither the store nor JointJS reaches the entry chunk. +if (import.meta.env.DEV) { + const [{ useStore }, { canvasRef }, { firebaseReady }] = await Promise.all([ + import('./store/store'), + import('./canvas/paperSetup'), + // env-only, no firebase/* imports — costs the dev entry chunk nothing + import('./auth/config'), + ]) + ;(window as unknown as Record).__pid = { useStore, canvasRef, firebaseReady } } -;(window as LaunchQueueWindow).launchQueue?.setConsumer((params) => { - void (async () => { - const handle = params.files?.[0] - if (!handle) return - const file = await handle.getFile() - const { loadAnyText } = await import('./persist/file') - loadAnyText(file.name, await file.text()) - })() -}) createRoot(document.getElementById('root')!).render( - + , ) diff --git a/src/model/costs.ts b/src/model/costs.ts index 09f4189..ae7ea71 100644 --- a/src/model/costs.ts +++ b/src/model/costs.ts @@ -1,159 +1,269 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { BudgetSettings, PlantNode, ProjectDoc } from './types' import { getSymbol } from '../symbols/registry' /** - * Budgetary (order-of-magnitude) hardware prices in USD — the estimating - * style of Matches' equipment cost database / AACE Class 5. Real prices vary - * several-fold with size, material, and rating, so every value here is an - * EDITABLE DEFAULT: the Budget dialog overrides per project, and each placed - * component can carry its own exact price. `installFactor` (Lang-style) - * scales hardware to installed cost. + * Budgetary (order-of-magnitude) hardware prices in USD, researched Aug 2026 + * against distributor and manufacturer listings, published cost correlations + * (Towler & Sinnott / Matches, CEPCI-escalated) and tender awards. Prices are + * FOB/ex-works, HARDWARE ONLY — no installation, freight, tax or engineering. + * + * Every value is an EDITABLE DEFAULT: the Budget dialog overrides per project, + * and each placed component can carry its own exact price. `installFactor` + * (Lang-style) scales hardware to installed cost. + * + * `low`/`high` bracket the realistic spread for the component class across + * size, material and rating — often 5-10x for equipment. `basis` states the + * size/rating the `price` is for; a different size needs a different number. + * `ev` is how the figure was established: 'sourced' means at least one + * published price was observed, 'est' means no vendor publishes one (typically + * quote-only kit — flares, fired heaters, MCCs, DCS) and the value comes from a + * cost correlation or a component build-up. */ export interface PriceEntry { price: number label: string + /** Realistic spread for the class, USD. */ + low?: number + high?: number + /** Size / rating / material the `price` is for. */ + basis?: string + /** 'sourced' = a published price was observed; 'est' = correlation or build-up. */ + ev?: 'sourced' | 'est' } export const DEFAULT_PRICES: Record = { + // --- instrument bubbles, classified from ISA tag letters --- - 'instr.element': { price: 400, label: 'Primary element (TE/FE/LE…)' }, - 'instr.transmitter': { price: 1500, label: 'Transmitter (FT/PT/LT/TT…)' }, - 'instr.indicator': { price: 350, label: 'Local indicator (PI/TI/FI…)' }, - 'instr.controller': { price: 2500, label: 'Controller / DCS loop share (FIC…)' }, - 'instr.switch': { price: 300, label: 'Process switch (LSH/PSL…)' }, - 'instr.converter': { price: 600, label: 'Converter / relay (FY/I-P…)' }, - 'instr.recorder': { price: 1200, label: 'Recorder (FR/TR…)' }, - 'instr.analyzer': { price: 15000, label: 'Analyzer (AT/AIT/AIC…)' }, - 'instr.generic': { price: 800, label: 'Instrument (other)' }, + 'instr.analyzer': { price: 4000, label: 'Analyzer (AT/AIT/AIC…)', low: 1000, high: 120000, basis: 'Complete single-parameter liquid analyser loop: 4-wire field/panel analyser-transmitter with 4-20 mA +…', ev: 'sourced' }, + 'instr.transmitter': { price: 3800, label: 'Transmitter (FT/PT/LT/TT…)', low: 600, high: 14500, basis: '2-wire 4-20 mA HART DP/gauge pressure transmitter as the reference: Rosemount 3051C coplanar class,…', ev: 'sourced' }, + 'instr.recorder': { price: 3200, label: 'Recorder (FR/TR…)', low: 790, high: 10200, basis: 'Panel-mount paperless (videographic) recorder, 5.7-7 in colour touchscreen, 6-12 universal analog…', ev: 'sourced' }, + 'instr.generic': { price: 2100, label: 'Instrument (other)', low: 200, high: 12000, basis: 'Population-weighted average of the eight buckets above at their stated bases, using a typical…', ev: 'sourced' }, + 'instr.controller': { price: 2000, label: 'Controller / DCS loop share (FIC…)', low: 850, high: 6500, basis: 'TWO bases, both given. (a) Standalone: 1/4 DIN panel-mount universal single-loop indicating PID…', ev: 'sourced' }, + 'instr.switch': { price: 700, label: 'Process switch (LSH/PSL…)', low: 130, high: 3200, basis: 'Field-mounted process switch, single SPDT snap-action output, NEMA 4X or Ex d enclosure, 316SS wetted…', ev: 'sourced' }, + 'instr.converter': { price: 500, label: 'Converter / relay (FY/I-P…)', low: 180, high: 1500, basis: 'Field-mounted current-to-pressure (I/P) transducer, 4-20 mA in, 3-15 psig out, general-purpose or NEMA…', ev: 'sourced' }, + 'instr.element': { price: 475, label: 'Primary element (TE/FE/LE…)', low: 120, high: 2200, basis: 'Complete industrial temperature element assembly as the reference case: Pt100 3-wire or Type K element,…', ev: 'sourced' }, + 'instr.indicator': { price: 225, label: 'Local indicator (PI/TI/FI…)', low: 55, high: 1800, basis: '4.5 in dial process pressure gauge, phenolic or SS case, 316SS bourdon tube, 1/2 in NPT lower…', ev: 'sourced' }, // --- control & actuated valves --- - 'cv.globe': { price: 4500, label: 'Control valve, globe' }, - 'cv.butterfly': { price: 3200, label: 'Control valve, butterfly' }, - 'cv.ball': { price: 3800, label: 'Control valve, ball' }, - 'valve.solenoid': { price: 700, label: 'Solenoid valve (XV)' }, - 'valve.mov': { price: 3500, label: 'Motor-operated valve' }, + 'cv.globe': { price: 8500, label: 'Control valve, globe', low: 4500, high: 20000, basis: '2 in (DN50), ASME Class 150 RF flanged, CS (WCC) body / 316SS trim, equal-percentage cage trim,…', ev: 'sourced' }, + 'cv.ball': { price: 7200, label: 'Control valve, ball', low: 3500, high: 17000, basis: '2 in (DN50), Class 150 RF flanged segmented (V-notch) ball rotary control valve, CS (WCC) body / 316SS…', ev: 'sourced' }, + 'valve.mov': { price: 6000, label: 'Motor-operated valve', low: 2500, high: 18000, basis: '2 in (DN50), Class 150 RF flanged CS (WCB) gate valve + electric MULTI-TURN actuator with integral…', ev: 'sourced' }, + 'cv.butterfly': { price: 5800, label: 'Control valve, butterfly', low: 2800, high: 14000, basis: '2 in (DN50), Class 150 lugged high-performance double-offset butterfly, CS body / 316SS disc / RTFE…', ev: 'sourced' }, + 'valve.solenoid': { price: 340, label: 'Solenoid valve (XV)', low: 150, high: 1400, basis: '1/4 in NPT, 3-way 2-position, brass body, direct-acting, Ex d / NEMA 7-9 encapsulated coil, 120 V AC or…', ev: 'sourced' }, // --- manual valves --- - 'valve.gate': { price: 350, label: 'Gate valve' }, - 'valve.globe': { price: 450, label: 'Globe valve' }, - 'valve.ball': { price: 250, label: 'Ball valve' }, - 'valve.butterfly': { price: 300, label: 'Butterfly valve' }, - 'valve.plug': { price: 400, label: 'Plug valve' }, - 'valve.needle': { price: 150, label: 'Needle valve' }, - 'valve.diaphragm': { price: 500, label: 'Diaphragm valve' }, - 'valve.check': { price: 250, label: 'Check valve' }, - 'valve.ballcheck': { price: 280, label: 'Ball check valve' }, - 'valve.pinch': { price: 450, label: 'Pinch valve' }, - 'valve.stopcheck': { price: 550, label: 'Stop-check valve' }, - 'valve.knife': { price: 600, label: 'Knife gate valve' }, - 'valve.threeway': { price: 650, label: '3-way valve' }, - 'valve.fourway': { price: 900, label: '4-way valve' }, - 'valve.angle': { price: 400, label: 'Angle valve' }, - 'valve.foot': { price: 200, label: 'Foot valve' }, - 'valve.float': { price: 250, label: 'Float valve' }, + 'valve.fourway': { price: 2200, label: '4-way valve', low: 350, high: 5000, basis: '2 in (DN50), ASME Class 150 flanged, 4-way (four-port) manual ball or plug valve, 316SS or CS body,…', ev: 'sourced' }, + 'valve.threeway': { price: 1400, label: '3-way valve', low: 220, high: 3000, basis: '2 in (DN50), ASME Class 150 flanged, 3-way L-port (diverting) or T-port (mixing) ball valve, 316SS…', ev: 'sourced' }, + 'valve.pinch': { price: 1100, label: 'Pinch valve', low: 240, high: 2500, basis: '2 in (DN50), flanged, manual handwheel, open-frame or enclosed body (aluminum/cast iron),…', ev: 'sourced' }, + 'valve.diaphragm': { price: 900, label: 'Diaphragm valve', low: 210, high: 2200, basis: '2 in (DN50), Class 150 flanged, weir type, manual handwheel — ductile iron/cast iron body with…', ev: 'sourced' }, + 'valve.ball': { price: 700, label: 'Ball valve', low: 300, high: 1600, basis: '2 in (DN50), ASME Class 150 RF flanged, carbon steel body, full port, RTFE/TFM seats, 316SS ball &…', ev: 'sourced' }, + 'valve.stopcheck': { price: 550, label: 'Stop-check valve', low: 300, high: 1500, basis: '2 in (DN50), ASME Class 150 RF flanged, cast carbon steel WCB, globe-pattern non-return with stem (Y-…', ev: 'sourced' }, + 'valve.plug': { price: 520, label: 'Plug valve', low: 250, high: 1500, basis: '2 in (DN50), Class 125/150 flanged, cast iron body, lubricated (Nordstrom-type) or sleeved, regular…', ev: 'sourced' }, + 'valve.globe': { price: 500, label: 'Globe valve', low: 230, high: 1800, basis: '2 in (DN50), ASME Class 150 RF flanged, cast carbon steel A216 WCB, bolted bonnet, OS&Y, API 600, 13Cr…', ev: 'sourced' }, + 'valve.angle': { price: 500, label: 'Angle valve', low: 200, high: 1400, basis: '2 in (DN50), angle-pattern globe valve, Class 125/150, bronze body with PTFE or bronze disc, FNPT ends…', ev: 'sourced' }, + 'valve.gate': { price: 450, label: 'Gate valve', low: 250, high: 1400, basis: '2 in (DN50), ASME Class 150 RF flanged, cast carbon steel A216 WCB body, bolted bonnet, OS&Y, API 600,…', ev: 'sourced' }, + 'valve.knife': { price: 450, label: 'Knife gate valve', low: 200, high: 1200, basis: '2 in (DN50), wafer body between ASME Class 150 flanges, 304/316SS or cast iron body with SS gate,…', ev: 'sourced' }, + 'valve.check': { price: 320, label: 'Check valve', low: 150, high: 900, basis: '2 in (DN50), ASME Class 150 RF flanged, cast carbon steel A216 WCB, bolted cover, swing disc, 13Cr…', ev: 'sourced' }, + 'valve.ballcheck': { price: 300, label: 'Ball check valve', low: 110, high: 800, basis: '2 in (DN50), Class 125/150 flanged, cast/ductile iron body, epoxy coated, elastomer-coated or nitrile…', ev: 'sourced' }, + 'valve.float': { price: 250, label: 'Float valve', low: 150, high: 3100, basis: '2 in (DN50), pipe-mount float valve (lever-arm ball float), FNPT threaded, bronze/lead-free brass body…', ev: 'sourced' }, + 'valve.butterfly': { price: 220, label: 'Butterfly valve', low: 80, high: 600, basis: '2 in (DN50), lug style (ASME Class 150 bolt pattern), 200 psi, epoxy-coated ductile iron body, 316SS…', ev: 'sourced' }, + 'valve.needle': { price: 180, label: 'Needle valve', low: 60, high: 450, basis: 'BASIS DEVIATION — priced as instrumentation needle valve: 1/2 in FNPT, 316SS bar-stock body, 6000 psi…', ev: 'sourced' }, + 'valve.foot': { price: 55, label: 'Foot valve', low: 35, high: 700, basis: '2 in (DN50), FNPT threaded, cast iron body with integral strainer and non-return disc, water suction…', ev: 'sourced' }, // --- safety & relief --- - psv: { price: 1800, label: 'Pressure safety valve' }, - pse: { price: 600, label: 'Rupture disc' }, - 'psv.pilot': { price: 3500, label: 'Pilot-operated PSV' }, - pvsv: { price: 2200, label: 'P/V conservation vent' }, - 'pcv.self': { price: 1200, label: 'Self-acting PCV' }, - 'tcv.self': { price: 1100, label: 'Self-acting TCV' }, - bpcv: { price: 1400, label: 'Back-pressure valve' }, - 'flame-arrestor': { price: 900, label: 'Flame arrestor' }, - breather: { price: 500, label: 'Breather valve' }, - 'vacuum-breaker': { price: 400, label: 'Vacuum breaker' }, + 'psv.pilot': { price: 14000, label: 'Pilot-operated PSV', low: 6000, high: 45000, basis: '2 in x 3 in (2J3-equivalent), Class 150 RF flanged, CS body with 316SS trim, modulating or pop-action…', ev: 'sourced' }, + psv: { price: 6500, label: 'Pressure safety valve', low: 1800, high: 33000, basis: '2 in x 3 in (2J3), J orifice (1.287 in2), Class 150 RF x Class 150 RF, conventional (closed screwed…', ev: 'sourced' }, + pvsv: { price: 4800, label: 'P/V conservation vent', low: 400, high: 9500, basis: '3 in (DN80) inlet weight/spring-loaded pressure-and-vacuum relief valve, pipe-away style, cast…', ev: 'sourced' }, + 'tcv.self': { price: 4500, label: 'Self-acting TCV', low: 1200, high: 9000, basis: 'Complete 2 in (DN50) self-acting temperature control set: 2-port control valve (cast iron / bronze / SG…', ev: 'sourced' }, + bpcv: { price: 3400, label: 'Back-pressure valve', low: 700, high: 9000, basis: '2 in (DN50) self-contained back-pressure (upstream-sensing) regulator, CS or ductile iron body, 316SS…', ev: 'sourced' }, + 'pcv.self': { price: 3200, label: 'Self-acting PCV', low: 550, high: 7500, basis: '2 in (DN50) self-operated spring-loaded pressure reducing regulator, cast steel (WCC) or ductile iron…', ev: 'sourced' }, + 'flame-arrestor': { price: 1900, label: 'Flame arrestor', low: 600, high: 20000, basis: '2 in (DN50) IN-LINE deflagration flame arrestor, cast aluminium housing with removable crimped-ribbon…', ev: 'sourced' }, + pse: { price: 420, label: 'Rupture disc', low: 60, high: 1200, basis: '2 in (DN50) reverse-acting scored 316SS rupture disc, ~10 barg (150 psig) burst @ 72 F, ASME UD…', ev: 'sourced' }, + 'vacuum-breaker': { price: 290, label: 'Vacuum breaker', low: 100, high: 1400, basis: '1/2 in (DN15) NPT vacuum breaker, brass or bronze body with 316SS internals and elastomer seal,…', ev: 'sourced' }, + breather: { price: 120, label: 'Breather valve', low: 35, high: 900, basis: '2 in (DN50) open/free vent — gooseneck or mushroom cap with insect screen, aluminium or brass, threaded…', ev: 'sourced' }, // --- flow elements --- - 'fe.orifice': { price: 800, label: 'Orifice plate + flanges' }, - 'fe.venturi': { price: 2500, label: 'Venturi tube' }, - 'fe.magmeter': { price: 3500, label: 'Magnetic flow meter' }, - 'fe.coriolis': { price: 9000, label: 'Coriolis meter' }, - 'fe.vortex': { price: 2800, label: 'Vortex meter' }, - 'fe.turbine': { price: 2000, label: 'Turbine meter' }, - 'fe.rotameter': { price: 400, label: 'Rotameter' }, - 'fe.ultrasonic': { price: 4500, label: 'Ultrasonic meter' }, - 'fe.thermal': { price: 2500, label: 'Thermal mass meter' }, - 'fe.pd': { price: 2200, label: 'PD meter' }, - 'fe.pitot': { price: 900, label: 'Pitot / averaging pitot' }, - 'fe.avgpitot': { price: 1100, label: 'Averaging pitot' }, - 'fe.ro': { price: 300, label: 'Restriction orifice' }, - 'fe.nozzle': { price: 1200, label: 'Flow nozzle' }, + 'fe.coriolis': { price: 13000, label: 'Coriolis meter', low: 7000, high: 30000, basis: '2 in (DN50) Coriolis meter, 316L wetted tubes, 2 in ASME Class 150 RF flanges, integral or remote 4-20…', ev: 'sourced' }, + 'fe.thermal': { price: 6500, label: 'Thermal mass meter', low: 2600, high: 11000, basis: '2 in (DN50) INLINE thermal mass flowmeter for gas (air / N2 / CH4), 316SS wetted, ASME Class 150…', ev: 'sourced' }, + 'fe.vortex': { price: 6000, label: 'Vortex meter', low: 3300, high: 9500, basis: '2 in (DN50) wafer or flanged vortex meter, 316L/CF-3M wetted, ASME Class 150, integral 4-20 mA / HART…', ev: 'sourced' }, + 'fe.ultrasonic': { price: 5800, label: 'Ultrasonic meter', low: 1700, high: 13000, basis: '2 in (DN50) pipe. TWO SCOPES: (a) INLINE spool-piece transit-time meter, 316SS wetted, Class 150…', ev: 'sourced' }, + 'fe.magmeter': { price: 4800, label: 'Magnetic flow meter', low: 2400, high: 9500, basis: '2 in (DN50) DN50 Class 150 RF flanged magmeter, PTFE/PFA or hard-rubber liner with 316L electrodes,…', ev: 'sourced' }, + 'fe.venturi': { price: 4500, label: 'Venturi tube', low: 1200, high: 9000, basis: '2 in (DN50) classical long-form venturi tube, ASME/ISO 5167-4 profile, fabricated 316SS, Class 150 RF…', ev: 'sourced' }, + 'fe.nozzle': { price: 4000, label: 'Flow nozzle', low: 1500, high: 9000, basis: '2 in (DN50) ASME MFC-3M / ISO 5167-3 long-radius flow nozzle, 316SS, welded-in throat with holding ring…', ev: 'sourced' }, + 'fe.pd': { price: 3200, label: 'PD meter', low: 700, high: 8000, basis: '2 in (DN50) positive-displacement meter (oval gear / rotary vane / nutating disc), 316SS or…', ev: 'sourced' }, + 'fe.rotameter': { price: 3100, label: 'Rotameter', low: 250, high: 5200, basis: '2 in (DN50) Class 150 flanged variable-area meter, 316SS wetted. TWO SCOPES PRICED: (a) armored…', ev: 'sourced' }, + 'fe.avgpitot': { price: 2800, label: 'Averaging pitot', low: 1700, high: 6100, basis: '2 in (DN50) line, 316SS averaging-pitot sensor with multiple upstream/downstream ports, threaded or…', ev: 'sourced' }, + 'fe.turbine': { price: 1800, label: 'Turbine meter', low: 700, high: 4500, basis: '2 in (DN50) inline axial turbine meter, 316SS body and rotor with tungsten-carbide bearings, wafer or…', ev: 'sourced' }, + 'fe.orifice': { price: 1300, label: 'Orifice plate + flanges', low: 350, high: 3500, basis: '2 in (DN50) orifice flange union, ASME B16.36 Class 300 RF weld-neck pair with 1/2 in NPT taps, jack…', ev: 'sourced' }, + 'fe.pitot': { price: 600, label: 'Pitot / averaging pitot', low: 120, high: 2500, basis: 'Single-point insertion pitot / pitot-static tube, 316SS, 1/8–3/8 in stem, 6–18 in insertion length,…', ev: 'sourced' }, + 'fe.ro': { price: 450, label: 'Restriction orifice', low: 120, high: 9000, basis: '2 in (DN50) 316SS restriction orifice PLATE, non-bevelled, sized for a fixed pressure letdown,…', ev: 'sourced' }, + + // --- instrument accessories --- + 'acc.loadcell': { price: 1000, label: 'Load Cell', low: 180, high: 3100, basis: 'Stainless steel compression load cell, 5 t (11,000 lb) rated capacity, IP67/68 welded, ~3 mV/V output,…', ev: 'sourced' }, + 'acc.floatcage': { price: 900, label: 'External Float Cage', low: 390, high: 5000, basis: 'External float chamber / cage only: 2 in NPT (or 2 in 150# RF side-side) x 1 in NPT process piping,…', ev: 'sourced' }, + 'acc.bulb': { price: 800, label: 'Filled Bulb + Capillary', low: 350, high: 1700, basis: 'Gas-actuated (Class II) filled-system dial thermometer: 4.5-5 in dial, 316SS bulb, ~10 ft armored 316SS…', ev: 'sourced' }, + 'acc.lg': { price: 750, label: 'Gauge Glass', low: 110, high: 2600, basis: 'One-section armored flat-glass REFLEX level gauge, ~11.5 in centre-to-centre (about 12 in visible), 1/2…', ev: 'sourced' }, + 'acc.radar': { price: 400, label: 'Radar Level Horn', low: 85, high: 1400, basis: 'HORN ONLY: 4 in / DN100 cone antenna with PTFE process seal and flanged process connection, 316L…', ev: 'sourced' }, + 'acc.pg': { price: 190, label: 'Pressure Gauge', low: 30, high: 520, basis: '4.5 in (114 mm) dial process gauge, 304SS case / 316SS bourdon and socket, glycerin filled, 1/4 or 1/2…', ev: 'sourced' }, + 'acc.seal': { price: 185, label: 'Diaphragm Seal', low: 60, high: 650, basis: 'Threaded diaphragm seal, 1/2 in NPT female process x 1/2 in NPT instrument, all-welded or bolted, 316L…', ev: 'sourced' }, + 'acc.afr': { price: 140, label: 'Air Filter Regulator', low: 20, high: 520, basis: '1/4 in NPT instrument-air filter-regulator with output gauge, 0-125 psig output (or 2-60 psig), 250…', ev: 'sourced' }, + 'acc.thermowell': { price: 90, label: 'Thermowell', low: 15, high: 460, basis: '316SS threaded thermowell, 3/4 in MNPT process x 1/2 in NPT female instrument connection, straight or…', ev: 'sourced' }, + 'acc.bimetal': { price: 85, label: 'Bimetal Thermometer', low: 18, high: 250, basis: '5 in dial industrial bimetal thermometer, 316SS stem 1/4 in dia x 6 in insertion, 1/2 in NPT,…', ev: 'sourced' }, + 'acc.siphon': { price: 50, label: 'Gauge Siphon (Pigtail)', low: 10, high: 300, basis: '1/4 in NPT male x 1/4 in NPT female pigtail (coil) gauge siphon, 316SS, ~5.5 in, Schedule 40, steam…', ev: 'sourced' }, // --- rotating --- - 'pump.centrifugal': { price: 4500, label: 'Centrifugal pump' }, - 'pump.gear': { price: 3500, label: 'PD pump (gear/screw)' }, - 'pump.diaphragm': { price: 2800, label: 'Diaphragm pump' }, - 'pump.peristaltic': { price: 3200, label: 'Peristaltic pump' }, - 'pump.plunger': { price: 5500, label: 'Plunger pump' }, - 'pump.submersible': { price: 3000, label: 'Submersible pump' }, - 'pump.vacuum': { price: 6500, label: 'Vacuum pump' }, - ejector: { price: 1500, label: 'Ejector / eductor' }, - 'comp.centrifugal': { price: 85000, label: 'Centrifugal compressor' }, - 'comp.recip': { price: 60000, label: 'Reciprocating compressor' }, - 'comp.screw': { price: 45000, label: 'Screw compressor' }, - blower: { price: 6000, label: 'Blower' }, - fan: { price: 3500, label: 'Fan' }, - agitator: { price: 8000, label: 'Agitator' }, - motor: { price: 2500, label: 'Electric motor' }, - vfd: { price: 3000, label: 'VFD' }, - 'turbine.steam': { price: 120000, label: 'Steam turbine' }, + 'comp.centrifugal': { price: 480000, label: 'Centrifugal compressor', low: 250000, high: 5000000, basis: 'Integrally geared multistage centrifugal process compressor, SMALL FRAME, 375 kW (500 hp) absorbed,…', ev: 'sourced' }, + 'turbine.steam': { price: 280000, label: 'Steam turbine', low: 80000, high: 8500000, basis: '500 kW class steam turbine, backpressure / single-stage, ~500 psig / 550 F inlet to 50 psig exhaust,…', ev: 'sourced' }, + 'comp.recip': { price: 60000, label: 'Reciprocating compressor', low: 2000, high: 1500000, basis: 'TWO cases. (a) Priced as typical: industrial two-stage lubricated reciprocating AIR compressor, 75 kW…', ev: 'sourced' }, + 'comp.screw': { price: 54000, label: 'Screw compressor', low: 12000, high: 95000, basis: '75 kW (100 hp) fixed-speed oil-injected rotary screw air compressor package, base mount, 125 psig, 481…', ev: 'sourced' }, + agitator: { price: 18000, label: 'Agitator', low: 4100, high: 60000, basis: 'Top-entry gear-drive agitator, 7.5 kW (10 hp), ~10 m3 (2,500 gal) vessel, ASME flange mount with single…', ev: 'sourced' }, + blower: { price: 16000, label: 'Blower', low: 3100, high: 60000, basis: 'Rotary positive-displacement (tri-lobe) blower, ~1,000 m3/h (600 icfm) at 0.5 barg / 7 psid, 15-22 kW…', ev: 'sourced' }, + 'pump.peristaltic': { price: 12000, label: 'Peristaltic pump', low: 400, high: 40000, basis: 'Industrial hose (peristaltic) pump, 25 mm / 1 in hose, ~8-15 gpm at 7 barg, cast iron pump body,…', ev: 'sourced' }, + motor: { price: 11000, label: 'Electric motor', low: 600, high: 60000, basis: '75 kW (100 hp), 1,800 rpm, TEFC, NEMA Premium efficiency, 460 V 3-phase, 405T cast-iron frame, foot…', ev: 'sourced' }, + 'pump.centrifugal': { price: 9500, label: 'Centrifugal pump', low: 3000, high: 28000, basis: 'ANSI/ASME B73.1 horizontal end-suction process pump, 2x3-8 hydraulic end, 100 gpm @ 150 ft TDH (~7.5-10…', ev: 'sourced' }, + 'pump.plunger': { price: 9000, label: 'Plunger pump', low: 900, high: 45000, basis: 'Triplex plunger pump, 10 gpm at 1,500 psi (~9 kW hydraulic, 12-15 hp motor), crankcase-driven, bare…', ev: 'sourced' }, + 'pump.vacuum': { price: 8000, label: 'Vacuum pump', low: 1000, high: 26000, basis: 'Single-stage liquid-ring vacuum pump, 150 cfm (255 m3/h) at 25 in Hg, cast iron body with bronze/CI…', ev: 'sourced' }, + vfd: { price: 6500, label: 'VFD', low: 300, high: 20000, basis: '75 kW (100 hp), 380-480 V 3-phase, IP20 / open-chassis general-purpose variable frequency drive, V/Hz…', ev: 'sourced' }, + 'pump.submersible': { price: 5200, label: 'Submersible pump', low: 1400, high: 14000, basis: '5 hp (3.7 kW), 3 in discharge, 460 V 3-phase submersible sewage/dewatering pump, cast iron casing and…', ev: 'sourced' }, + 'pump.diaphragm': { price: 3800, label: 'Diaphragm pump', low: 350, high: 13000, basis: 'TWO distinct items. (a) AODD: 2 in air-operated double-diaphragm pump, bolted, ~150-180 gpm max, PTFE…', ev: 'sourced' }, + fan: { price: 2400, label: 'Fan', low: 1000, high: 25000, basis: '24 in tube-axial fan with motor and V-belt drive package, ~9,000 cfm (15,000 m3/h) at low static…', ev: 'sourced' }, + ejector: { price: 1800, label: 'Ejector / eductor', low: 60, high: 12000, basis: '2 in liquid-jet eductor or single-stage steam-jet ejector, cast iron or bronze body, NPT or 150#…', ev: 'sourced' }, + 'pump.gear': { price: 1500, label: 'PD pump (gear/screw)', low: 700, high: 13000, basis: 'Internal gear pump (Viking-class), 1-1.5 in NPT ports, 20 gpm at 100 psi differential, cast iron casing…', ev: 'sourced' }, // --- vessels --- - 'vessel.vertical': { price: 15000, label: 'Vertical vessel' }, - 'vessel.horizontal': { price: 18000, label: 'Horizontal vessel' }, - 'vessel.tank': { price: 25000, label: 'Storage tank' }, - 'vessel.column-tray': { price: 90000, label: 'Trayed column' }, - 'vessel.column-packed': { price: 70000, label: 'Packed column' }, - 'vessel.cstr': { price: 60000, label: 'Stirred reactor' }, - 'vessel.ko-drum': { price: 20000, label: 'KO drum' }, - 'vessel.floating-roof': { price: 120000, label: 'Floating-roof tank' }, - 'vessel.sphere': { price: 250000, label: 'Sphere' }, - 'vessel.bullet': { price: 80000, label: 'Bullet' }, - 'vessel.open': { price: 9000, label: 'Open tank' }, - 'vessel.silo': { price: 30000, label: 'Silo' }, - 'vessel.fixedbed': { price: 45000, label: 'Fixed-bed reactor' }, - 'vessel.sep3': { price: 35000, label: '3-phase separator' }, - cyclone: { price: 8000, label: 'Cyclone' }, + 'vessel.sphere': { price: 1400000, label: 'Sphere', low: 450000, high: 3000000, basis: '1,500 m3 LPG Horton sphere, ~14.2 m dia, carbon steel SA-537 Cl.1, design pressure 1.77 MPa (17.7…', ev: 'sourced' }, + 'vessel.floating-roof': { price: 1300000, label: 'Floating-roof tank', low: 650000, high: 2200000, basis: '5,000 m3 / ~31,450 bbl external floating-roof tank, API 650 App. C, ~22 m dia x 14 m high, carbon…', ev: 'sourced' }, + 'vessel.column-packed': { price: 295000, label: 'Packed column', low: 90000, high: 700000, basis: 'Same shell as the tray column: 1.5 m ID x 20 m T-T CS, 12 mm wall, design 5 barg, skirt, 4 manways,…', ev: 'sourced' }, + 'vessel.column-tray': { price: 230000, label: 'Trayed column', low: 75000, high: 650000, basis: '1.5 m ID x 20 m T-T carbon steel column, 20 valve trays at 0.6 m spacing (CS trays and downcomers),…', ev: 'sourced' }, + 'vessel.cstr': { price: 200000, label: 'Stirred reactor', low: 30000, high: 420000, basis: '5 m3 (1,320 US gal) jacketed agitated reactor, SS316L wetted parts, CS jacket, ASME VIII Div 1, 10 barg…', ev: 'sourced' }, + 'vessel.fixedbed': { price: 175000, label: 'Fixed-bed reactor', low: 60000, high: 700000, basis: 'Fixed-bed catalytic reactor sized for 5 m3 of catalyst: 1.5 m ID x ~4 m bed depth (5.5 m T-T), carbon…', ev: 'sourced' }, + 'vessel.bullet': { price: 150000, label: 'Bullet', low: 45000, high: 330000, basis: '100 m3 / ~26,400 US gal horizontal ASME LPG bullet, ~2.8 m dia x 16 m OAL, carbon steel SA-612, design…', ev: 'sourced' }, + 'vessel.sep3': { price: 125000, label: '3-phase separator', low: 40000, high: 450000, basis: 'Horizontal 3-phase (oil/water/gas) separator, 2.0 m ID x 6.0 m S/S, carbon steel SA-516-70, design 10…', ev: 'sourced' }, + 'vessel.tank': { price: 80000, label: 'Storage tank', low: 45000, high: 260000, basis: '100 m3 / ~26,400 US gal vertical atmospheric storage tank, API 650, fixed cone roof, flat bottom, ~5.0…', ev: 'sourced' }, + 'vessel.vertical': { price: 62000, label: 'Vertical vessel', low: 18000, high: 130000, basis: '10 m3 vertical pressure vessel, 1.8 m ID x 4.0 m T-T, carbon steel SA-516-70, design 10 barg / 165 psig…', ev: 'sourced' }, + 'vessel.horizontal': { price: 55000, label: 'Horizontal vessel', low: 16000, high: 115000, basis: '10 m3 horizontal pressure vessel, 1.8 m ID x 4.0 m T-T, carbon steel SA-516-70, design 10 barg / 165…', ev: 'sourced' }, + 'vessel.ko-drum': { price: 55000, label: 'KO drum', low: 18000, high: 130000, basis: '5 m3 knock-out drum, 1.4 m ID x 3.3 m T-T, carbon steel SA-516-70, design 10 barg / 165 psig, ASME VIII…', ev: 'sourced' }, + 'vessel.silo': { price: 31000, label: 'Silo', low: 12000, high: 75000, basis: '50 m3 / 1,766 ft3 carbon steel silo, ~3.0 m dia cylinder with 60-degree cone bottom, ~5 mm plate,…', ev: 'sourced' }, + 'vessel.open': { price: 14000, label: 'Open tank', low: 3500, high: 38000, basis: '10 m3 open-top vertical tank, ~2.2 m dia x 2.7 m high, carbon steel A36, 6 mm shell and bottom, no…', ev: 'sourced' }, + cyclone: { price: 9900, label: 'Cyclone', low: 2500, high: 45000, basis: 'Single gas-solid reverse-flow cyclone, 5,000 m3/h (~2,950 acfm) at near-ambient conditions,…', ev: 'sourced' }, // --- heat transfer --- - 'hx.shell-tube': { price: 18000, label: 'Shell & tube exchanger' }, - 'hx.plate': { price: 12000, label: 'Plate exchanger' }, - 'hx.air-cooler': { price: 35000, label: 'Air cooler' }, - 'hx.kettle': { price: 30000, label: 'Kettle reboiler' }, - 'hx.doublepipe': { price: 7000, label: 'Double-pipe exchanger' }, - 'hx.condenser': { price: 20000, label: 'Condenser' }, - 'hx.coil': { price: 1500, label: 'Heating/cooling coil' }, - 'heater.electric': { price: 6000, label: 'Electric heater' }, - 'heater.fired': { price: 150000, label: 'Fired heater' }, - 'cooling-tower': { price: 45000, label: 'Cooling tower' }, + 'heater.fired': { price: 2400000, label: 'Fired heater', low: 600000, high: 12000000, basis: '10 MW (34 MMBtu/hr) absorbed duty, vertical cylindrical process heater, CS tube coil, natural-gas…', ev: 'sourced' }, + 'cooling-tower': { price: 680000, label: 'Cooling tower', low: 180000, high: 2500000, basis: '10 MW heat rejection (approx 2,300 nominal CT tons, 2,000 m3/h at 5 degC range), induced-draft…', ev: 'sourced' }, + 'hx.air-cooler': { price: 125000, label: 'Air cooler', low: 45000, high: 480000, basis: '200 m2 bare-tube area (approx 2,400 m2 extended/finned), 2 bays, CS tubes with aluminium fins, 2 x 15…', ev: 'sourced' }, + 'hx.kettle': { price: 76000, label: 'Kettle reboiler', low: 30000, high: 320000, basis: '100 m2 U-tube kettle reboiler, CS shell / CS tubes, TEMA BKU, 10 barg shell-side steam / 10 barg…', ev: 'sourced' }, + 'hx.condenser': { price: 66000, label: 'Condenser', low: 25000, high: 290000, basis: '120 m2 shell-and-tube overhead condenser, TEMA BEM, CS shell / CS tubes, cooling water in tubes at 6…', ev: 'sourced' }, + 'hx.shell-tube': { price: 58000, label: 'Shell & tube exchanger', low: 22000, high: 260000, basis: '100 m2 effective surface, TEMA BEM fixed-tubesheet, CS shell / CS tubes, 150 psig / 10 barg both sides,…', ev: 'sourced' }, + 'hx.plate': { price: 33000, label: 'Plate exchanger', low: 14000, high: 95000, basis: '100 m2 gasketed plate-and-frame, 0.5 mm 316SS plates, NBR gaskets, painted CS frame, 10 barg / 150…', ev: 'sourced' }, + 'hx.doublepipe': { price: 19000, label: 'Double-pipe exchanger', low: 7000, high: 55000, basis: '15 m2 multitube hairpin, CS shell / CS inner tubes, 2 x 6 m hairpin sections, 40 barg tube side / 10…', ev: 'sourced' }, + 'heater.electric': { price: 11000, label: 'Electric heater', low: 3500, high: 180000, basis: '36 kW flanged circulation heater, 316SS wetted (sheath and vessel), DN80 150# flanges, Incoloy-800…', ev: 'sourced' }, + 'hx.coil': { price: 8000, label: 'Heating/cooling coil', low: 2500, high: 30000, basis: '8 m2 helical immersion coil, 316SS Sch 10 DN50 pipe, ~50 m developed length, 10 barg steam or cooling…', ev: 'sourced' }, + + // --- inline fittings, strainers & traps --- + 'fit.rupture-pin': { price: 3500, label: 'Rupture Pin Valve', low: 1500, high: 9000, basis: '2 in (DN50) Class 150 flanged rupture-pin relief valve, 316SS trim, buckling-pin actuated…', ev: 'est' }, + 'fit.trap-float': { price: 3200, label: 'Float Steam Trap', low: 700, high: 6000, basis: '2 in (DN50) Class 150 flanged float & thermostatic trap, ductile-iron body, 200 psig (Spirax FT14-14HC…', ev: 'sourced' }, + 'fit.silencer': { price: 2500, label: 'Silencer', low: 800, high: 15000, basis: '2 in inlet steam vent / blowdown silencer, carbon steel shell with SS internals and absorptive/diffuser…', ev: 'sourced' }, + 'fit.hose-station': { price: 1800, label: 'Utility Hose Station', low: 700, high: 5000, basis: 'Wall-mounted 3-service utility station: steam and cold-water mixing (thermostatic or manual mixer),…', ev: 'est' }, + 'fit.trap-bucket': { price: 1500, label: 'Inverted Bucket Trap', low: 180, high: 3500, basis: '2 in NPT/flanged inverted bucket trap, cast iron body with integral strainer, 150-250 psig (Armstrong…', ev: 'sourced' }, + 'sample.cooler': { price: 1500, label: 'Sample Cooler', low: 800, high: 6000, basis: 'Shell-and-coil sample cooler, 316SS coil and shell, ~2-4 kW duty (boiler/steam sample service, 1/2 in…', ev: 'sourced' }, + 'fit.pulsation-dampener': { price: 1400, label: 'Pulsation Dampener', low: 400, high: 5000, basis: '316SS bladder/diaphragm pulsation dampener, ~1-2 gallon (4-8 litre) volume, PTFE diaphragm, 1-2 in…', ev: 'sourced' }, + 'mixer.static': { price: 1100, label: 'Static Mixer', low: 250, high: 3500, basis: '2 in (DN50) Sch 40 316/316L stainless static mixer, 6 helical elements, Class 150 flanged ends (Koflo…', ev: 'sourced' }, + 'fit.exhaust-head': { price: 1000, label: 'Exhaust Head', low: 400, high: 3200, basis: '2 in NPT cast-iron exhaust head with SS separating internals and drain connection (Watson McDaniel…', ev: 'sourced' }, + 'fit.sightglass': { price: 850, label: 'Sight Glass', low: 250, high: 2500, basis: '2 in (DN50) Class 150 flanged inline sight flow indicator, 316SS body, double-window with flapper or…', ev: 'sourced' }, + 'strainer.basket': { price: 800, label: 'Basket Strainer', low: 450, high: 2500, basis: '2 in (DN50) Class 150 flanged simplex basket strainer, carbon steel body, SS basket, bolted cover', ev: 'sourced' }, + 'fit.trap-thermo': { price: 700, label: 'Thermodynamic Trap', low: 250, high: 1500, basis: '1/2 in NPT all-stainless thermodynamic disc trap (Spirax TD42 class) — TD traps are inherently…', ev: 'sourced' }, + 'filter.cartridge': { price: 600, label: 'Filter', low: 250, high: 6000, basis: 'Single-cartridge 10 in length 316SS liquid filter housing, 300 psi, 1-2 in NPT/flanged connections;…', ev: 'sourced' }, + 'fit.coupling': { price: 600, label: 'Quick Coupling', low: 60, high: 2200, basis: '2 in dry-disconnect (dry-break) coupler x 2 in female NPT, 150 psi — aluminium for the low case, 316SS…', ev: 'sourced' }, + demister: { price: 550, label: 'Demister Pad', low: 250, high: 1600, basis: 'Knitted 316SS wire-mesh mist eliminator pad, ~100 mm (4 in) thick, standard density ~145 kg/m3, with…', ev: 'sourced' }, + 'fit.steam-trap': { price: 450, label: 'Steam Trap', low: 120, high: 3500, basis: 'Generic process steam trap, 3/4 in NPT, stainless or cast-iron body, up to ~150 psig — the size…', ev: 'sourced' }, + 'fit.mixing-tee': { price: 450, label: 'Mixing Tee', low: 120, high: 1800, basis: '2 in (DN50) Class 150 flanged mixing tee, 316SS body with an internal injection nozzle/insert on the…', ev: 'est' }, + 'strainer.y': { price: 400, label: 'Y-Strainer', low: 120, high: 1200, basis: '2 in (DN50) Class 150 flanged, A216 WCB carbon steel body, 316SS perforated screen', ev: 'sourced' }, + 'fit.expansion': { price: 400, label: 'Expansion Joint', low: 120, high: 2500, basis: 'BOTH given at 2 in (DN50) Class 150 flanged: (a) twin-sphere rubber joint, EPDM/neoprene, 150 psi; (b)…', ev: 'sourced' }, + 'fit.sample': { price: 350, label: 'Sample Point', low: 120, high: 1200, basis: '3/4 in 316SS sample connection off a 2 in line: nipple + 316SS ball valve + needle throttling valve +…', ev: 'est' }, + 'fit.quill': { price: 350, label: 'Injection Quill', low: 150, high: 1600, basis: '1/2 in NPT 316SS injection quill with integral check valve, ~8-12 in insertion length, 3,000 psi body…', ev: 'sourced' }, + 'fit.hose': { price: 320, label: 'Hose', low: 90, high: 900, basis: '2 in ID x 3 ft (0.9 m) overall length T316L stainless corrugated hose with 300-series SS braid,…', ev: 'sourced' }, + 'fit.drain': { price: 220, label: 'Drain', low: 90, high: 700, basis: '1 in valved drain off a 2 in line: 1 in CS/316SS nipple + 1 in ball or gate valve + plug/cap, threaded', ev: 'est' }, + 'fit.funnel': { price: 200, label: 'Drain Funnel (Tundish)', low: 80, high: 600, basis: '304/316SS drain tundish, ~4-6 in bowl with 2 in outlet, bracket-mounted, open-to-atmosphere', ev: 'est' }, + 'fit.vent': { price: 180, label: 'Vent', low: 70, high: 600, basis: '3/4 in valved vent off a 2 in line: 3/4 in nipple + 3/4 in CS/316SS ball valve + plug, threaded', ev: 'est' }, + 'fit.spectacle': { price: 130, label: 'Spectacle Blind', low: 60, high: 400, basis: '2 in Class 150 ASME B16.48 spectacle blind, A516 Gr 70 carbon steel, with handle, painted', ev: 'sourced' }, + 'strainer.cone': { price: 120, label: 'Temporary Cone Strainer', low: 45, high: 350, basis: '2 in (DN50) Class 150 temporary conical (witch-hat) strainer, CS ring with 316SS mesh over perforated…', ev: 'est' }, + 'fit.spade': { price: 95, label: 'Spade / Spacer', low: 45, high: 300, basis: '2 in Class 150 ASME B16.48 paddle blank (spade) plus matching ring spacer, A516 Gr 70 carbon steel,…', ev: 'sourced' }, + 'fit.flanges': { price: 65, label: 'Flange Pair', low: 35, high: 220, basis: 'One 2 in Class 150 RF joint: 2 x A105 CS weld-neck flanges + 1 x 1/16 in spiral-wound 316/graphite…', ev: 'sourced' }, + 'fit.junction': { price: 30, label: 'Branch Junction', low: 12, high: 220, basis: '2 in (DN50) Sch 40 butt-weld equal tee, A234 WPB carbon steel; flanged 150# CS tee quoted as the high…', ev: 'sourced' }, + 'fit.union': { price: 30, label: 'Union', low: 12, high: 120, basis: '2 in Class 3000 threaded union, A105 carbon steel, integral seat', ev: 'sourced' }, + 'fit.blind': { price: 28, label: 'Blind Flange', low: 16, high: 90, basis: '2 in Class 150 RF blind flange, A105 carbon steel (gasket and bolts not included)', ev: 'sourced' }, + 'fit.reducer-ecc': { price: 22, label: 'Eccentric Reducer', low: 10, high: 110, basis: '2 in x 1 in Sch 40 butt-weld eccentric reducer, A234 WPB carbon steel', ev: 'sourced' }, + 'fit.reducer': { price: 18, label: 'Reducer', low: 8, high: 90, basis: '2 in x 1 in Sch 40 butt-weld concentric reducer, A234 WPB carbon steel', ev: 'sourced' }, + 'fit.specbreak': { price: 0, label: 'Spec Break', low: 0, high: 0, basis: 'DRAWING SYMBOL ONLY — no hardware', ev: 'est' }, + + // --- solids handling & separation --- + crystallizer: { price: 700000, label: 'Crystallizer', low: 100000, high: 4000000, basis: 'Continuous forced-circulation vacuum crystallizer, ~5 t/h crystal product, 316SS body ~3 m dia, incl.…', ev: 'sourced' }, + 'dryer.rotary': { price: 600000, label: 'Rotary Dryer', low: 80000, high: 2500000, basis: 'Direct co-current rotary drum dryer, 1.8 m dia x 12 m long, ~1,500 kg/h water evaporation, CS shell…', ev: 'sourced' }, + 'mill.ball': { price: 500000, label: 'Ball Mill', low: 80000, high: 3000000, basis: 'Wet overflow ball mill, ~2.6 m dia x 4.0 m EGL, 20 t/h ore feed, ~600 kW ring-gear drive, incl. shell,…', ev: 'sourced' }, + evaporator: { price: 400000, label: 'Evaporator', low: 60000, high: 2500000, basis: 'Single-effect falling-film evaporator, 100 m2 heat-transfer area, 316SS tubes and shell, ~4,000 kg/h…', ev: 'sourced' }, + clarifier: { price: 350000, label: 'Clarifier / Thickener', low: 60000, high: 1500000, basis: '20 m diameter circular centre-drive clarifier/thickener MECHANISM: half-bridge, drive head with torque…', ev: 'sourced' }, + 'dryer.spray': { price: 350000, label: 'Spray Dryer', low: 40000, high: 3000000, basis: 'Co-current spray dryer, ~100 kg/h water evaporation, 316SS product contact, rotary-atomiser or…', ev: 'sourced' }, + centrifuge: { price: 280000, label: 'Centrifuge', low: 30000, high: 900000, basis: 'BOTH priced at ~10 m3/h feed, 316SS wetted, skid-mounted with drive and control panel, ex-works. (a)…', ev: 'sourced' }, + 'filter.rotary': { price: 250000, label: 'Rotary Drum Filter', low: 50000, high: 900000, basis: 'Rotary drum vacuum filter, 20 m2 filtration area (~2.4 m dia x 2.7 m face), 316SS drum and trough,…', ev: 'sourced' }, + extruder: { price: 250000, label: 'Extruder', low: 25000, high: 1100000, basis: 'Co-rotating intermeshing TWIN-screw extruder, ~500 kg/h, 50–60 mm screw dia, L/D 40, segmented barrels…', ev: 'sourced' }, + crusher: { price: 180000, label: 'Crusher / Mill', low: 25000, high: 800000, basis: 'Primary single-toggle jaw crusher, 50 t/h at 100 mm CSS, ~600 x 400 mm feed opening, 45 kW drive,…', ev: 'sourced' }, + 'filter.press': { price: 110000, label: 'Filter Press', low: 15000, high: 500000, basis: 'Overhead-beam automatic filter press, 1000 x 1000 mm plates, 50 m2 filtration area, ~700 L cake volume,…', ev: 'sourced' }, + scrubber: { price: 95000, label: 'Scrubber', low: 15000, high: 600000, basis: 'Vertical counter-current packed-bed wet scrubber, 17,000 m3/h (~10,000 acfm), ~1.8 m dia x 6 m tall…', ev: 'sourced' }, + 'screen.vibrating': { price: 85000, label: 'Vibrating Screen', low: 6000, high: 250000, basis: 'Inclined circular-motion 2-deck vibrating screen, 1.8 m x 4.9 m deck (~8.8 m2 per deck), ~150 t/h…', ev: 'sourced' }, + 'blender.ribbon': { price: 75000, label: 'Ribbon Blender', low: 12000, high: 350000, basis: 'Horizontal double-ribbon blender, 2.8 m3 (100 ft3) working volume, ~1.2 m x 3.7 m U-trough, 304SS…', ev: 'sourced' }, + bagfilter: { price: 70000, label: 'Bag House', low: 20000, high: 500000, basis: 'Pulse-jet reverse-air baghouse, 17,000 m3/h (~10,000 acfm) at 0.5 bar, ~230 m2 of filter cloth…', ev: 'sourced' }, + coalescer: { price: 65000, label: 'Coalescer', low: 6000, high: 350000, basis: 'Vertical liquid/liquid cartridge coalescer, ~30 m3/h (~130 gpm), 9–13 element ASME Sec VIII CS vessel…', ev: 'sourced' }, + 'bucket-elevator': { price: 55000, label: 'Bucket Elevator', low: 8000, high: 200000, basis: 'Centrifugal-discharge belt-and-bucket elevator, 15 m lift height, 30 t/h dry granular solids, carbon…', ev: 'sourced' }, + 'dryer.tray': { price: 40000, label: 'Tray Dryer', low: 8000, high: 250000, basis: 'Batch tray (truck-and-tray) cabinet dryer, 48 trays of ~0.1 m2 (~5 m2 total tray area), 2 trolleys,…', ev: 'sourced' }, + 'conveyor.belt': { price: 36000, label: 'Belt Conveyor', low: 12000, high: 90000, basis: '20 m centres x 600 mm troughed belt conveyor, carbon steel frame and stringers, 3-roll idlers, EP-250…', ev: 'sourced' }, + 'conveyor.screw': { price: 14000, label: 'Screw Conveyor', low: 4000, high: 45000, basis: '9 in (230 mm) dia CDDP-style carbon-steel trough screw conveyor, 6 m long, sealed ends, 4 kW…', ev: 'sourced' }, + hydrocyclone: { price: 9000, label: 'Hydrocyclone', low: 600, high: 150000, basis: 'SINGLE unit: 250 mm (10 in) dia hydrocyclone, ~100 m3/h feed at 1.5 bar, CS/GRP body with replaceable…', ev: 'sourced' }, + 'feeder.rotary': { price: 6500, label: 'Rotary Feeder', low: 2000, high: 30000, basis: '10 in (250 mm) square-flanged drop-through rotary airlock, cast-iron housing, 8-vane closed rotor,…', ev: 'sourced' }, + + // --- utility packages & electrical --- + flare: { price: 750000, label: 'Flare', low: 250000, high: 3500000, basis: 'Elevated self-supported/derrick flare, 35 m stack, 24 in riser, 50 t/h relief capacity, smokeless…', ev: 'est' }, + boiler: { price: 300000, label: 'Boiler', low: 110000, high: 950000, basis: '10 t/h (approx 22,000 lb/hr, 640 BHP) 4-pass wetback packaged firetube, 10 barg / 150 psig design,…', ev: 'sourced' }, + 'package-unit': { price: 280000, label: 'Package unit', low: 40000, high: 2500000, basis: 'PLACEHOLDER SYMBOL — a vendor-supplied, shop-assembled skid delivered as one purchase order with its…', ev: 'est' }, + stack: { price: 180000, label: 'Stack', low: 45000, high: 900000, basis: '30 m self-supporting CS stack, 1.2 m internal diameter, 8 mm shell, unlined (no refractory), flanged…', ev: 'est' }, + 'elec.mcc': { price: 104000, label: 'MCC', low: 44000, high: 520000, basis: '480 V 3ph 65 kA NEMA 1 gasketed indoor MCC lineup, 1600 A horizontal bus, ~20 vertical sections with…', ev: 'sourced' }, + deaerator: { price: 95000, label: 'Deaerator', low: 35000, high: 320000, basis: '20 t/h (approx 44,000 lb/hr) spray-tray deaerator, CS pressure vessel with 316SS spray valves and…', ev: 'sourced' }, + chiller: { price: 85000, label: 'Chiller', low: 45000, high: 175000, basis: '100 ton (352 kW) air-cooled packaged process chiller, scroll compressors, R-454B, integral pump and…', ev: 'sourced' }, + 'elec.transformer': { price: 70000, label: 'Transformer', low: 18000, high: 190000, basis: '1000 kVA, 13.8 kV delta / 480-277 V wye, three-phase oil-filled pad-mount (dead-front, loop-feed), 65…', ev: 'sourced' }, + 'filter-sep': { price: 32000, label: 'Filter separator', low: 9000, high: 140000, basis: 'DN150 (6 in) horizontal two-stage gas filter separator, 1,000 m3/h at 20 barg, CS ASME Section VIII Div…', ev: 'est' }, + 'elec.ups': { price: 16000, label: 'UPS', low: 9100, high: 75000, basis: '20 kVA / 18 kW three-phase online double-conversion UPS, 480 V in / 208 V out (or 400 V), 30-minute…', ev: 'sourced' }, + 'air-dryer': { price: 11000, label: 'Air dryer', low: 2700, high: 38000, basis: '200 scfm (340 Nm3/h) heatless twin-tower regenerative desiccant dryer, -40 degC pressure dewpoint, 10…', ev: 'sourced' }, + 'elec.barrier': { price: 300, label: 'IS barrier', low: 150, high: 620, basis: 'One channel of a DIN-rail intrinsically safe isolating barrier for a 4-20 mA HART loop with 24 V DC…', ev: 'sourced' }, - // --- utilities & packages --- - boiler: { price: 90000, label: 'Boiler' }, - flare: { price: 60000, label: 'Flare' }, - stack: { price: 25000, label: 'Stack' }, - chiller: { price: 35000, label: 'Chiller' }, - deaerator: { price: 30000, label: 'Deaerator' }, - 'air-dryer': { price: 9000, label: 'Air dryer' }, - 'package-unit': { price: 50000, label: 'Package unit' }, - 'filter-sep': { price: 7000, label: 'Filter separator' }, - 'elec.mcc': { price: 25000, label: 'MCC' }, - 'elec.transformer': { price: 20000, label: 'Transformer' }, - 'elec.ups': { price: 8000, label: 'UPS' }, - 'elec.barrier': { price: 400, label: 'IS barrier' }, + // --- control hardware (logic symbols carry no hardware cost) --- + 'ctl.dcs': { price: 450000, label: 'DCS', low: 250000, high: 1100000, basis: '500 I/O distributed control system, ~350 AI/AO 4-20 mA HART plus ~150 DI/DO, redundant controllers and…', ev: 'sourced' }, + 'ctl.sis': { price: 260000, label: 'SIS', low: 85000, high: 700000, basis: '100 I/O SIL 3 capable safety instrumented system: TUV-certified triple-modular-redundant or 1oo2D logic…', ev: 'sourced' }, + 'ctl.plc': { price: 24000, label: 'PLC System', low: 5500, high: 90000, basis: 'Mid-range PLC control system, 128 I/O (approx 64 DI, 32 DO, 24 AI, 8 AO), single non-redundant…', ev: 'sourced' }, + 'ctl.panel': { price: 4500, label: 'Local Panel', low: 1200, high: 40000, basis: 'Local control panel, 800 x 600 x 250 mm painted steel IP55 wall-mounted enclosure, non-hazardous area,…', ev: 'est' }, + 'ctl.jb': { price: 1104, label: 'Junction Box', low: 600, high: 2640, basis: 'Field junction box, Ex e increased safety, 316L stainless steel enclosure approx 300 x 400 x 150 mm,…', ev: 'sourced' }, + 'ctl.interlock': { price: 0, label: 'Interlock', low: 0, high: 0, basis: 'NOT HARDWARE — a P&ID/logic-diagram symbol denoting an interlock relationship (typically an ISA-5.2…', ev: 'est' }, + 'logic.and': { price: 0, label: 'AND Gate', low: 0, high: 0, basis: 'NOT HARDWARE — an ISA-5.2 binary logic symbol on a logic diagram. No purchase order, no FOB price.…', ev: 'est' }, + 'logic.or': { price: 0, label: 'OR Gate', low: 0, high: 0, basis: 'NOT HARDWARE — an ISA-5.2 binary logic symbol on a logic diagram. No purchase order, no FOB price.…', ev: 'est' }, + 'logic.not': { price: 0, label: 'NOT Gate', low: 0, high: 0, basis: 'NOT HARDWARE — an ISA-5.2 binary logic symbol (signal inversion) on a logic diagram. No purchase order,…', ev: 'est' }, - // --- category fallbacks (anything not listed above) --- - 'cat.valves': { price: 350, label: 'Valve (other)' }, - 'cat.control-valves': { price: 4000, label: 'Control valve (other)' }, - 'cat.safety': { price: 1200, label: 'Safety device (other)' }, - 'cat.flow-elements': { price: 1500, label: 'Flow element (other)' }, - 'cat.accessories': { price: 300, label: 'Instrument accessory' }, - 'cat.rotating': { price: 5000, label: 'Rotating equipment (other)' }, - 'cat.vessels': { price: 20000, label: 'Vessel (other)' }, - 'cat.heat': { price: 15000, label: 'Heat transfer (other)' }, - 'cat.inline': { price: 250, label: 'Fitting / inline item' }, - 'cat.control': { price: 1500, label: 'Control hardware' }, - 'cat.instruments': { price: 800, label: 'Instrument (other)' }, + // --- category fallbacks: median of the researched prices in each category. + // Only reached by custom/imported symbols now that every library symbol + // has an exact entry above. cat.inline is bimodal (a $18 reducer and a + // $700k crystallizer share the category), so treat it as a placeholder. --- + 'cat.accessories': { price: 190, label: 'Instrument accessory' }, + 'cat.control': { price: 1104, label: 'Control hardware' }, + 'cat.control-valves': { price: 7200, label: 'Control valve (other)' }, + 'cat.flow-elements': { price: 3600, label: 'Flow element (other)' }, + 'cat.heat': { price: 62000, label: 'Heat transfer (other)' }, + 'cat.inline': { price: 3200, label: 'Fitting / inline item' }, + 'cat.instruments': { price: 500, label: 'Instrument (other)' }, + 'cat.rotating': { price: 9500, label: 'Rotating equipment (other)' }, + 'cat.safety': { price: 3300, label: 'Safety device (other)' }, + 'cat.valves': { price: 500, label: 'Valve (other)' }, + 'cat.vessels': { price: 125000, label: 'Vessel (other)' }, 'cat.custom': { price: 1000, label: 'Custom symbol' }, } diff --git a/src/model/currency.ts b/src/model/currency.ts new file mode 100644 index 0000000..eef1628 --- /dev/null +++ b/src/model/currency.ts @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +/** + * Display currencies for the budget estimator. + * + * Every price in the tool is stored in USD — the price table, the per-project + * overrides and `budget.total` alike — so a project opens with the same numbers + * whoever made it. Currency is a display choice applied on the way out, and on + * the way back in when someone types into a field. + * + * Rates are indicative and frozen at the date below rather than fetched: an + * order-of-magnitude estimator should not silently change its own numbers + * because an FX API moved overnight, and a budgetary estimate carries far more + * uncertainty than a few percent of currency drift. Override any unit price in + * the Budget dialog if your market says otherwise. + */ +export interface Currency { + code: string + symbol: string + name: string + /** Units of this currency per 1 USD. */ + rate: number + /** Decimal places to show. Currencies with tiny units show none. */ + dp: number +} + +export const FX_DATE = '27 Aug 2026' + +export const CURRENCIES: Currency[] = [ + { code: 'USD', symbol: '$', name: 'US dollar', rate: 1, dp: 0 }, + { code: 'EUR', symbol: '€', name: 'Euro', rate: 0.857811, dp: 0 }, + { code: 'GBP', symbol: '£', name: 'Pound sterling', rate: 0.735113, dp: 0 }, + { code: 'INR', symbol: '₹', name: 'Indian rupee', rate: 95.465863, dp: 0 }, + { code: 'JPY', symbol: '¥', name: 'Japanese yen', rate: 159.234947, dp: 0 }, + { code: 'CNY', symbol: 'CN¥', name: 'Chinese yuan', rate: 6.737816, dp: 0 }, + { code: 'AED', symbol: 'AED ', name: 'UAE dirham', rate: 3.6725, dp: 0 }, + { code: 'SAR', symbol: 'SAR ', name: 'Saudi riyal', rate: 3.75, dp: 0 }, + { code: 'AUD', symbol: 'A$', name: 'Australian dollar', rate: 1.393007, dp: 0 }, + { code: 'CAD', symbol: 'C$', name: 'Canadian dollar', rate: 1.387087, dp: 0 }, + { code: 'CHF', symbol: 'CHF ', name: 'Swiss franc', rate: 0.805051, dp: 0 }, + { code: 'SGD', symbol: 'S$', name: 'Singapore dollar', rate: 1.270935, dp: 0 }, +] + +/** Docs saved before currencies had codes stored the bare symbol. */ +const LEGACY: Record = { $: 'USD', '€': 'EUR', '₹': 'INR', '£': 'GBP', '¥': 'JPY' } + +export function currencyOf(code: string | undefined): Currency { + const want = code ? (LEGACY[code] ?? code) : 'USD' + return CURRENCIES.find((c) => c.code === want) ?? CURRENCIES[0]! +} + +/** USD -> display currency. */ +export const toDisplay = (usd: number, cur: Currency): number => usd * cur.rate + +/** Display currency -> USD, for values typed into a field. */ +export const toUsd = (shown: number, cur: Currency): number => shown / cur.rate + +/** Full amount with symbol, e.g. "₹9,54,659" -> "₹954,659". */ +export function money(usd: number, cur: Currency): string { + const v = toDisplay(usd, cur) + return cur.symbol + v.toLocaleString('en-US', { minimumFractionDigits: cur.dp, maximumFractionDigits: cur.dp }) +} + +/** Compact amount for the toolbar chip: 12400 -> "12k", 1.3e6 -> "1.3M". */ +export function moneyShort(usd: number, cur: Currency): string { + const v = toDisplay(usd, cur) + const a = Math.abs(v) + if (a >= 1e9) return `${cur.symbol}${(v / 1e9).toFixed(a >= 1e10 ? 0 : 1)}B` + if (a >= 1e6) return `${cur.symbol}${(v / 1e6).toFixed(a >= 1e7 ? 0 : 1)}M` + if (a >= 1e4) return `${cur.symbol}${(v / 1e3).toFixed(0)}k` + if (a >= 1e3) return `${cur.symbol}${(v / 1e3).toFixed(1)}k` + return cur.symbol + Math.round(v).toLocaleString('en-US') +} + +/** + * Scale units for entering large budgets, so nobody has to count zeros. + * Indian currencies get lakh/crore, everyone else gets K/M/B. + */ +export interface ScaleUnit { + label: string + /** Display-currency units per 1 of this scale. */ + mult: number + title: string +} + +const WESTERN: ScaleUnit[] = [ + { label: '—', mult: 1, title: 'plain units' }, + { label: 'K', mult: 1e3, title: 'thousand' }, + { label: 'M', mult: 1e6, title: 'million' }, + { label: 'B', mult: 1e9, title: 'billion' }, +] + +const INDIAN: ScaleUnit[] = [ + { label: '—', mult: 1, title: 'plain rupees' }, + { label: 'K', mult: 1e3, title: 'thousand' }, + { label: 'Lakh', mult: 1e5, title: '1 lakh = 1,00,000' }, + { label: 'Cr', mult: 1e7, title: '1 crore = 1,00,00,000 (100 lakh)' }, +] + +export const scaleUnits = (cur: Currency): ScaleUnit[] => (cur.code === 'INR' ? INDIAN : WESTERN) + +/** The largest unit that still leaves a value of 1 or more — what a person would say out loud. */ +export function bestScale(shown: number, units: ScaleUnit[]): ScaleUnit { + const a = Math.abs(shown) + if (!Number.isFinite(a) || a < 1) return units[0]! + return [...units].reverse().find((u) => a >= u.mult) ?? units[0]! +} + +/** Trim a scaled value for an input: 7.0500 -> "7.05", 60 -> "60". */ +export function trimNum(v: number): string { + if (!Number.isFinite(v)) return '' + return String(Number(v.toFixed(4))) +} diff --git a/src/model/datasheet.ts b/src/model/datasheet.ts index 15c9cf9..6ff38b2 100644 --- a/src/model/datasheet.ts +++ b/src/model/datasheet.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + export interface DatasheetField { key: string label: string diff --git a/src/model/doc.ts b/src/model/doc.ts index e05ee95..b0d53e1 100644 --- a/src/model/doc.ts +++ b/src/model/doc.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { ulid } from 'ulid' import type { ProjectDoc, Sheet, SheetSize } from './types' @@ -46,7 +50,7 @@ export const DEFAULT_FLUIDS = [ export function createEmptyDoc(name = 'Untitled P&ID'): ProjectDoc { const now = new Date().toISOString() return { - schemaVersion: 4, + schemaVersion: 5, meta: { name, author: '', created: now, modified: now }, settings: { gridPx: 8, tagSeparator: '-', numberStart: 100 }, sheets: [createSheet(1)], diff --git a/src/model/fields.ts b/src/model/fields.ts new file mode 100644 index 0000000..ad15b6e --- /dev/null +++ b/src/model/fields.ts @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import type { EntityKind } from './registry' +import { DATASHEET_SECTIONS } from './datasheet' + +export interface FieldDef { + key: string + label: string +} + +export interface FieldSection { + id: string + title: string + fields: FieldDef[] +} + +/** + * What an engineering record is asked for, per kind of object. + * + * The instrument catalog IS the existing ISA-20 datasheet form — the same field + * keys, so a migrated `node.datasheet` transfers verbatim and the datasheet + * editor and this panel are two views of one record rather than two stores. + * + * This is a fixed catalog for now. From v0.17 the active company standard + * chooses the sections and marks fields required; the shape here is what that + * profile will supply. + */ +export const FIELD_CATALOG: Record = { + instrument: [ + { id: 'general', title: 'Identity & Service', fields: DATASHEET_SECTIONS.general }, + { id: 'process', title: 'Process Conditions', fields: DATASHEET_SECTIONS.process }, + { id: 'element', title: 'Element / Body', fields: DATASHEET_SECTIONS.element }, + { id: 'signal', title: 'Signal & Electrical', fields: DATASHEET_SECTIONS.signal }, + ], + valve: [ + { + id: 'general', + title: 'Identity & Service', + fields: [ + { key: 'general.service', label: 'Service' }, + { key: 'general.area', label: 'Area / Unit' }, + { key: 'general.line', label: 'Line' }, + { key: 'general.pid', label: 'P&ID No.' }, + { key: 'general.manufacturer', label: 'Manufacturer' }, + { key: 'general.model', label: 'Model' }, + ], + }, + { + id: 'element', + title: 'Body & Trim', + fields: [ + { key: 'element.size', label: 'Size' }, + { key: 'element.rating', label: 'Pressure class / rating' }, + { key: 'element.bodyMaterial', label: 'Body material' }, + { key: 'element.trim', label: 'Trim' }, + { key: 'element.connection', label: 'End connection' }, + { key: 'element.characteristic', label: 'Flow characteristic' }, + { key: 'element.cv', label: 'Cv / Kv' }, + ], + }, + { + id: 'actuation', + title: 'Actuation', + fields: [ + { key: 'actuation.actuator', label: 'Actuator type' }, + { key: 'actuation.failPosition', label: 'Fail position (FC/FO/FL)' }, + { key: 'actuation.signal', label: 'Command signal' }, + { key: 'actuation.positioner', label: 'Positioner' }, + { key: 'actuation.airSupply', label: 'Air supply' }, + ], + }, + ], + equipment: [ + { + id: 'general', + title: 'Identity & Service', + fields: [ + { key: 'general.service', label: 'Service' }, + { key: 'general.area', label: 'Area / Unit' }, + { key: 'general.type', label: 'Equipment type' }, + { key: 'general.pid', label: 'P&ID No.' }, + { key: 'general.manufacturer', label: 'Manufacturer' }, + { key: 'general.model', label: 'Model' }, + ], + }, + { + id: 'duty', + title: 'Duty & Capacity', + fields: [ + { key: 'duty.capacity', label: 'Capacity / flow' }, + { key: 'duty.head', label: 'Head / differential' }, + { key: 'duty.power', label: 'Driver power' }, + { key: 'duty.speed', label: 'Speed' }, + { key: 'duty.designPressure', label: 'Design pressure' }, + { key: 'duty.designTemperature', label: 'Design temperature' }, + ], + }, + { + id: 'construction', + title: 'Construction', + fields: [ + { key: 'construction.material', label: 'Material' }, + { key: 'construction.volume', label: 'Volume / area' }, + { key: 'construction.insulation', label: 'Insulation' }, + { key: 'construction.connections', label: 'Nozzle schedule' }, + ], + }, + ], + line: [ + { + id: 'general', + title: 'Identity & Service', + fields: [ + { key: 'general.service', label: 'Service' }, + { key: 'general.fluid', label: 'Fluid' }, + { key: 'general.from', label: 'From' }, + { key: 'general.to', label: 'To' }, + { key: 'general.pid', label: 'P&ID No.' }, + ], + }, + { + id: 'spec', + title: 'Pipe Specification', + fields: [ + { key: 'spec.size', label: 'Nominal size' }, + { key: 'spec.class', label: 'Pipe class / rating' }, + { key: 'spec.material', label: 'Material' }, + { key: 'spec.schedule', label: 'Schedule / thickness' }, + ], + }, + { + id: 'design', + title: 'Design Conditions', + fields: [ + { key: 'design.pressure', label: 'Design pressure' }, + { key: 'design.temperature', label: 'Design temperature' }, + { key: 'design.operatingPressure', label: 'Operating pressure' }, + { key: 'design.operatingTemperature', label: 'Operating temperature' }, + { key: 'design.insulation', label: 'Insulation' }, + { key: 'design.tracing', label: 'Tracing' }, + { key: 'design.testPressure', label: 'Test pressure' }, + ], + }, + ], +} + +/** Every field key a kind can hold, for CSV headers and completeness maths. */ +export function fieldKeysFor(kind: EntityKind): string[] { + return FIELD_CATALOG[kind].flatMap((s) => s.fields.map((f) => f.key)) +} + +const LABELS: Record = Object.fromEntries( + Object.values(FIELD_CATALOG).flatMap((sections) => + sections.flatMap((s) => s.fields.map((f) => [f.key, f.label] as const)), + ), +) + +export function labelForField(key: string): string { + return LABELS[key] ?? key +} diff --git a/src/model/fluidFlow.ts b/src/model/fluidFlow.ts index a81c49d..651454b 100644 --- a/src/model/fluidFlow.ts +++ b/src/model/fluidFlow.ts @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import type { PlantNode, SheetContent } from './types' import { isPortEnd } from './types' import { isProcessClass } from '../canvas/lineStyle' diff --git a/src/model/migrate.ts b/src/model/migrate.ts index 346e92a..b8a2c79 100644 --- a/src/model/migrate.ts +++ b/src/model/migrate.ts @@ -1,5 +1,11 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { ulid } from 'ulid' import type { PlantEdge, PlantNode, ProjectDoc, Sheet, SheetSize } from './types' +import type { Registry } from './registry' +import { keyOfNode, kindOfNode } from './registry' import { checkWidgetProps } from '../hmi/model' export class DocError extends Error {} @@ -31,7 +37,7 @@ function migrateV1(v1: V1Doc): ProjectDoc { edges: v1.edges, } return { - schemaVersion: 4, + schemaVersion: 5, meta: { name: v1.meta.name, author: v1.meta.author, created: v1.meta.created, modified: v1.meta.modified }, settings: v1.settings, sheets: [sheet], @@ -39,6 +45,47 @@ function migrateV1(v1: V1Doc): ProjectDoc { } } +/** + * schemaVersion 4 → 5: engineering data moves out of `node.datasheet` (keyed by + * node id, destroyed by a redraw) and into `doc.registry` (keyed by tag). + * + * Nothing is deleted. `node.datasheet` is left in place and still read as a + * fallback for one release, so a v5 document opened by a v4 build keeps working + * and a half-migrated file can never lose values. + * + * A datasheet on an UNTAGGED node has no tag to key on. Rather than drop it, it + * is parked under `__unassigned:` where the QA engine can surface it + * and the user can assign a tag; silently binning someone's filled-in datasheet + * because the symbol was never tagged would be the worst possible outcome. + */ +function buildRegistry(doc: ProjectDoc): Registry | undefined { + const registry: Registry = { ...(doc.registry ?? {}) } + let touchedAny = false + + for (const sheet of doc.sheets ?? []) { + for (const node of sheet.nodes ?? []) { + const datasheet = node.datasheet + if (!datasheet || Object.keys(datasheet).length === 0) continue + const kind = kindOfNode(node) + if (!kind) continue + const key = keyOfNode(node) ?? `__unassigned:${node.id}` + const existing = registry[key] + // A record already carrying values wins: re-running the migration over an + // already-migrated document must not clobber later edits. + registry[key] = { + key, + kind, + ...existing, + fields: { ...datasheet, ...(existing?.fields ?? {}) }, + } + touchedAny = true + } + } + + if (!touchedAny && !doc.registry) return undefined + return registry +} + /** Parse + validate + migrate a raw JSON payload into the current schema. */ export function loadDoc(raw: unknown): ProjectDoc { if (typeof raw !== 'object' || raw === null) { @@ -54,7 +101,7 @@ export function loadDoc(raw: unknown): ProjectDoc { if (typeof v1.settings !== 'object' || v1.settings === null) throw new DocError('Document is missing settings') return migrateV1(v1 as V1Doc) } - if (version === 2 || version === 3 || version === 4) { + if (version === 2 || version === 3 || version === 4 || version === 5) { const doc = raw as Partial if (!Array.isArray(doc.sheets) || doc.sheets.length === 0) throw new DocError('Document has no sheets') for (const sheet of doc.sheets) { @@ -87,7 +134,12 @@ export function loadDoc(raw: unknown): ProjectDoc { } } } - return { ...doc, schemaVersion: 4, hmiScreens: doc.hmiScreens ?? [] } as ProjectDoc + if (doc.registry !== undefined && (typeof doc.registry !== 'object' || doc.registry === null || Array.isArray(doc.registry))) { + throw new DocError('registry is malformed') + } + const migrated = { ...doc, schemaVersion: 5, hmiScreens: doc.hmiScreens ?? [] } as ProjectDoc + const registry = buildRegistry(migrated) + return registry ? { ...migrated, registry } : migrated } throw new DocError(`Unsupported schema version: ${String(version)}`) } diff --git a/src/model/registry.ts b/src/model/registry.ts new file mode 100644 index 0000000..261bd83 --- /dev/null +++ b/src/model/registry.ts @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import type { PlantEdge, PlantNode } from './types' +import { formatTag } from '../isa/tag' + +/** + * The engineering record: what an object *is*, as opposed to where it is drawn. + * + * Records are keyed by TAG, never by node id. A tag is the engineering + * identity; a node is one placement of it. That matters for four reasons: + * + * 1. The same tag legitimately appears more than once — an off-page + * continuation, a valve shown on two sheets, a header on a utility drawing. + * 2. Delete-and-redraw is normal drafting. Node-id keying silently destroys an + * approved datasheet; tag keying survives it. + * 3. Every deliverable already joins on tag — the instrument index, the loop + * derivation, the loop diagram, the HMI widget binding. + * 4. It makes a duplicate tag a DATA error rather than a cosmetic one. + * + * The trade-off is deliberate: an untagged object gets no record. "Tag it + * before you can spec it" is how an engineering database works, and the QA + * engine offers to assign the next free tag rather than leaving you stuck. + */ +export type EntityKind = 'instrument' | 'valve' | 'equipment' | 'line' + +export type RecordStatus = 'draft' | 'in-review' | 'approved' | 'issued' + +export const RECORD_STATUSES: readonly RecordStatus[] = ['draft', 'in-review', 'approved', 'issued'] + +export interface EngineeringRecord { + /** Canonical identity: 'FT-101', 'P-101', '6"-CS-CW-001'. */ + key: string + kind: EntityKind + /** Values keyed by field id from the catalog in model/fields.ts. */ + fields: Record + status?: RecordStatus + owner?: string + /** Revision in which this record last changed (populated from v0.19). */ + rev?: string + updated?: string +} + +export type Registry = Record + +/** + * Valves are their own kind rather than a flavour of equipment: a control valve + * needs body/trim/Cv/fail-position/actuator, which is neither the instrument + * catalog nor the pump one, and valves are a large share of any P&ID. + */ +export function kindOfNode(node: PlantNode): EntityKind | null { + if (node.kind === 'annotation') return null + if (node.kind === 'instrument') return 'instrument' + if (node.kind === 'valve') return 'valve' + return 'equipment' +} + +/** The record key for a node, or null when it has no engineering identity. */ +export function keyOfNode(node: PlantNode): string | null { + if (node.kind === 'annotation') return null + if (!node.tag?.letters || !node.tag.loop) return null + return formatTag(node.tag, '-') +} + +/** The record key for a line: its line number, the same string the line list + * prints. A line with no number has no identity to hang a record on. */ +export function keyOfEdge(edge: PlantEdge): string | null { + const ln = edge.lineNumber + if (!ln) return null + const key = [ln.size, ln.spec, ln.service, ln.seq].filter(Boolean).join('-') + return key || null +} + +export function emptyRecord(key: string, kind: EntityKind): EngineeringRecord { + return { key, kind, fields: {} } +} + +/** + * Carry a record across a rename. + * + * - The old key has a record and the new one does not → MOVE it. A rename is + * the same object under a new name; losing its spec would be a data loss bug. + * - The old key is still worn by another object on some sheet → COPY, because + * that other object still needs its record. + * - The new key ALREADY has a record → do not touch either one and report a + * collision. Silently merging two engineering records is unrecoverable, and + * a duplicate tag is already a finding the user must resolve. + */ +export function retagRegistry( + registry: Registry | undefined, + oldKey: string | null, + newKey: string | null, + opts: { oldKeyStillUsed: boolean }, +): { registry: Registry | undefined; collision: boolean } { + if (!registry || !oldKey || !newKey || oldKey === newKey) return { registry, collision: false } + const existing = registry[oldKey] + if (!existing) return { registry, collision: false } + if (registry[newKey]) return { registry, collision: true } + + const next: Registry = { ...registry, [newKey]: { ...existing, key: newKey } } + if (!opts.oldKeyStillUsed) delete next[oldKey] + return { registry: next, collision: false } +} + +/** Every key currently worn by something on a sheet — the live set a record + * can be checked against to find orphans. */ +export function liveKeys(sheets: { nodes: PlantNode[]; edges: PlantEdge[] }[]): Set { + const keys = new Set() + for (const sheet of sheets) { + for (const n of sheet.nodes) { + const k = keyOfNode(n) + if (k) keys.add(k) + } + for (const e of sheet.edges) { + const k = keyOfEdge(e) + if (k) keys.add(k) + } + } + return keys +} + +/** + * Read one engineering value for a node: the record first, then the object's + * legacy `node.datasheet`. + * + * The fallback is what makes the schemaVersion 5 move non-destructive. A + * document written before the registry existed still shows every value it had, + * and the first edit promotes it into the record. Remove the fallback only + * once documents in the wild have been through a migrating save. + */ +export function fieldValue(registry: Registry | undefined, node: PlantNode, fieldKey: string): string { + const key = keyOfNode(node) + const fromRecord = key ? registry?.[key]?.fields[fieldKey] : undefined + return fromRecord ?? node.datasheet?.[fieldKey] ?? '' +} diff --git a/src/model/types.ts b/src/model/types.ts index b010df7..2316fbf 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1,6 +1,11 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + /** Core document types — the single source of truth for a P&ID drawing. */ import type { HmiScreen } from '../hmi/model' +import type { Registry } from './registry' export type SheetSize = 'A4' | 'A3' | 'A2' | 'A1' | 'ANSI_B' | 'ANSI_D' @@ -63,10 +68,12 @@ export interface PlantNode { /** User-dragged offsets (element-frame px) for the tag pair and the label. */ tagOffset?: { x: number; y: number } labelOffset?: { x: number; y: number } - attrs?: Record /** Off-page connector pairing to a connector on another sheet. */ link?: { sheetId: string; nodeId: string } - /** ISA-20-style datasheet values, keyed by datasheet field key. */ + /** ISA-20-style datasheet values, keyed by datasheet field key. + * @deprecated since schemaVersion 5 — engineering data lives in + * `ProjectDoc.registry`, keyed by tag so it survives a redraw. Still READ as + * a fallback for one release so older documents lose nothing. */ datasheet?: Record /** Exact per-instance price (beats project overrides and table defaults). */ cost?: number @@ -137,7 +144,7 @@ export interface CustomSymbolDef { } export interface ProjectDoc { - schemaVersion: 4 + schemaVersion: 5 meta: ProjectMeta settings: { gridPx: number @@ -154,6 +161,9 @@ export interface ProjectDoc { fluids?: Fluid[] /** Project budget & pricing (v0.10.0+, optional). */ budget?: BudgetSettings + /** Engineering records keyed by tag / line number (v0.15.0+, schemaVersion 5). + * See model/registry.ts for why the key is the tag and not the node id. */ + registry?: Registry } /** Budget settings: display currency, optional target, Lang-style installed diff --git a/src/panels/AccountMenu.tsx b/src/panels/AccountMenu.tsx new file mode 100644 index 0000000..b2fd403 --- /dev/null +++ b/src/panels/AccountMenu.tsx @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { useEffect, useRef, useState } from 'react' +import Popover from './Popover' +import { useStore } from '../store/store' +import { useAuthStore, signOutUser } from '../auth/authStore' +import { authErrorMessage } from '../auth/errors' +import CloudDialog from '../cloud/CloudDialog' +import { saveToCloud } from '../cloud/sync' + +/** Toolbar account control. Signed out it is a single Sign in button; signed + * in it opens a small menu over the cloud drawings. Sign-in is never required + * to draw — this is additive to the local-first editor, not a gate. */ +export default function AccountMenu() { + const user = useAuthStore((s) => s.user) + const ready = useAuthStore((s) => s.ready) + const [cloudOpen, setCloudOpen] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [status, setStatus] = useState(null) + const [saving, setSaving] = useState(false) + const btnRef = useRef(null) + + + useEffect(() => { + if (!status) return + const id = window.setTimeout(() => setStatus(null), 4000) + return () => window.clearTimeout(id) + }, [status]) + + const saveCurrent = async () => { + if (!user) return + setMenuOpen(false) + setSaving(true) + try { + const { doc, cloudId, setCloudId, markSaved } = useStore.getState() + const saved = await saveToCloud(user.uid, doc, { id: cloudId ?? undefined, name: doc.meta.name }) + setCloudId(saved.id) + markSaved() + setStatus('Saved to your account') + } catch (err) { + setStatus(authErrorMessage(err)) + } finally { + setSaving(false) + } + } + + // The editor sits behind the sign-in screen, so there is normally a user by + // the time this renders. The guard is for the moment between signing out and + // the route swapping back to the gate. + if (!ready || !user) return null + + const label = user.displayName?.trim() || user.email || 'Account' + + return ( +
+ + {menuOpen && ( + setMenuOpen(false)} className="tb-account-menu" testId="account-pop"> +
{label}
+
+ + +
+ setMenuOpen(false)}> + Buy me a coffee ☕ + + + + )} + {status && {status}} + {cloudOpen && setCloudOpen(false)} />} +
+ ) +} diff --git a/src/panels/AdvisorPanel.tsx b/src/panels/AdvisorPanel.tsx index 46b81cb..fd55d4b 100644 --- a/src/panels/AdvisorPanel.tsx +++ b/src/panels/AdvisorPanel.tsx @@ -1,5 +1,8 @@ -import { useMemo } from 'react' -import { runSuggestions } from '../validate/suggest' +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { issuesFor } from '../validate/issues' import { applyFix } from '../assist/fixes' import { useStore } from '../store/store' import { locateCell } from './ValidationPanel' @@ -16,8 +19,7 @@ const RULE_LABELS: Record = { } export function useSuggestions() { - const doc = useStore((s) => s.doc) - return useMemo(() => runSuggestions(doc), [doc]) + return issuesFor(useStore((s) => s.doc)).suggestions } export default function AdvisorPanel() { diff --git a/src/panels/BudgetDialog.tsx b/src/panels/BudgetDialog.tsx index 6aaafd1..37c7726 100644 --- a/src/panels/BudgetDialog.tsx +++ b/src/panels/BudgetDialog.tsx @@ -1,134 +1,270 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { useEffect, useState } from 'react' import Modal from './Modal' import { useStore } from '../store/store' -import { projectCost } from '../model/costs' +import { DEFAULT_PRICES, projectCost } from '../model/costs' +import { + CURRENCIES, FX_DATE, bestScale, currencyOf, money, moneyShort, + scaleUnits, toDisplay, toUsd, trimNum, +} from '../model/currency' import { download } from '../export/csv' -const CURRENCIES = ['$', '€', '₹', '£', '¥'] const FACTORS: [number, string][] = [ [1, '1× — hardware only'], + [1.5, '1.5× — hardware + freight & spares'], [3, '3× — typical installed cost (piping, wiring, labor)'], [5, '5× — full plant, Lang factor (engineering + construction)'], ] -const money = (cur: string, v: number) => `${cur}${Math.round(v).toLocaleString('en-US')}` +/** + * The toolbar's budget entry point: shows the live estimate as you draw and + * opens the breakdown. Lives in the top bar next to Fluids — the running cost + * is something you steer by while drawing, not a status readout. + */ +export function BudgetChip() { + const doc = useStore((s) => s.doc) + const [open, setOpen] = useState(false) + const { total } = projectCost(doc) + const cur = currencyOf(doc.budget?.currency) + const target = doc.budget?.total + const over = target !== undefined && total > target + const empty = total === 0 && target === undefined + return ( + <> + + {open && setOpen(false)} />} + + ) +} /** Project budget & cost estimate: set the target, tune unit prices, see the - * live breakdown. Prices are budgetary defaults (order-of-magnitude, USD - * scale) — override any of them for your market. */ + * live breakdown. Prices are budgetary USD defaults — override for your market. */ export default function BudgetDialog({ onClose }: { onClose(): void }) { const doc = useStore((s) => s.doc) const setBudget = useStore((s) => s.setBudget) const setPriceOverride = useStore((s) => s.setPriceOverride) const budget = doc.budget - const cur = budget?.currency ?? '$' + const cur = currencyOf(budget?.currency) const report = projectCost(doc) - const remaining = budget?.total !== undefined ? budget.total - report.total : undefined + const target = budget?.total + const remaining = target !== undefined ? target - report.total : undefined + const factor = budget?.installFactor ?? 1 + const pct = target !== undefined && target > 0 ? Math.min(report.total / target, 1.35) : undefined + + // Budget entry scale, so nobody types 70500000: pick "Cr" and type 7.05. + // Auto-picked from the stored value, then left alone once chosen; re-picked + // when the currency changes because lakh/crore only apply to rupees. + const units = scaleUnits(cur) + const shownTarget = target === undefined ? 0 : toDisplay(target, cur) + const [unitLabel, setUnitLabel] = useState(() => bestScale(shownTarget, units).label) + useEffect(() => { + setUnitLabel(bestScale(target === undefined ? 0 : toDisplay(target, cur), units).label) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cur.code]) + const unit = units.find((u) => u.label === unitLabel) ?? units[0]! const exportCsv = () => { const esc = (s: string) => (/[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s) const rows = [ - ['Item', 'Count', `Unit price (${cur})`, `Subtotal (${cur})`], - ...report.lines.map((l) => [l.label, String(l.count), String(l.unit), String(l.subtotal)]), + ['Item', 'Qty', `Unit (${cur.code})`, `Subtotal (${cur.code})`, 'Unit (USD)', 'Basis', 'Evidence'], + ...report.lines.map((l) => { + const e = DEFAULT_PRICES[l.key] + return [ + l.label, String(l.count), + String(Math.round(toDisplay(l.unit, cur))), + String(Math.round(toDisplay(l.subtotal, cur))), + String(l.unit), e?.basis ?? '', e?.ev ?? '', + ] + }), + [], + ['Hardware subtotal', '', '', String(Math.round(toDisplay(report.hardware, cur)))], + ['Install factor', '', '', String(factor)], + ['Estimated total', '', '', String(Math.round(toDisplay(report.total, cur)))], + ...(target !== undefined ? [['Budget', '', '', String(Math.round(toDisplay(target, cur)))]] : []), [], - ['Hardware subtotal', '', '', String(report.hardware)], - ['Install factor', '', '', String(budget?.installFactor ?? 1)], - ['Estimated total', '', '', String(report.total)], - ...(budget?.total !== undefined ? [['Budget', '', '', String(budget.total)]] : []), + [`Currency ${cur.code} at ${cur.rate} per USD, indicative rate of ${FX_DATE}`], ] - download(`${doc.meta.name || 'diagram'}-cost-estimate.csv`, rows.map((r) => r.map(esc).join(',')).join('\n'), 'text/csv') + download( + `${doc.meta.name || 'diagram'}-cost-estimate.csv`, + rows.map((r) => r.map(esc).join(',')).join('\n'), + 'text/csv', + ) } return ( - -
-
-
) } diff --git a/src/panels/UpdateToast.tsx b/src/panels/UpdateToast.tsx index dc0f5ab..d070815 100644 --- a/src/panels/UpdateToast.tsx +++ b/src/panels/UpdateToast.tsx @@ -1,4 +1,9 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useEffect, useState } from 'react' +import { forceRefresh } from './VersionNote' let doUpdate: (() => Promise) | null = null @@ -8,6 +13,7 @@ let doUpdate: (() => Promise) | null = null * existed in the HMI). */ export default function UpdateToast() { const [ready, setReady] = useState(false) + const [busy, setBusy] = useState(false) useEffect(() => { if (!import.meta.env.PROD) return let iv: number | undefined @@ -31,10 +37,37 @@ export default function UpdateToast() { } }, []) if (!ready) return null + + /** + * "Reload now" has to actually reload. + * + * vite-plugin-pwa's updateSW ignores its reloadPage argument in prompt mode: + * all it does is post SKIP_WAITING, and the reload rides on a `controlling` + * event that only fires when there IS a waiting worker to skip. If the new + * worker already activated, or workbox-window failed to import (leaving the + * skip-waiting sender undefined), the click was a silent no-op. So ask + * nicely first, then guarantee it. + */ + const reload = () => { + setBusy(true) + void (async () => { + try { + await doUpdate?.() + } catch { + // the fallback below is the thing that actually gets the user unstuck + } + })() + // If the service worker handshake works, the page is gone long before + // this fires. If it does not, this clears the caches and reloads anyway. + window.setTimeout(() => { void forceRefresh() }, 1500) + } + return (
⟳ A new version of IPD Studio is ready - +
) } diff --git a/src/panels/ValidationPanel.tsx b/src/panels/ValidationPanel.tsx index 4287bef..6980845 100644 --- a/src/panels/ValidationPanel.tsx +++ b/src/panels/ValidationPanel.tsx @@ -1,5 +1,8 @@ -import { useMemo } from 'react' -import { runChecks } from '../validate/checks' +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { issuesFor } from '../validate/issues' import { useStore } from '../store/store' import { canvasRef } from '../canvas/paperSetup' @@ -12,9 +15,10 @@ const CHECK_LABELS: Record = { 'duplicate-line-number': 'Duplicate line numbers', } +/** Reads the shared per-document pass — see validate/issues.ts for why this + * is not a `useMemo` of its own. */ export function useFindings() { - const doc = useStore((s) => s.doc) - return useMemo(() => runChecks(doc), [doc]) + return issuesFor(useStore((s) => s.doc)).findings } export function locateCell(targetId: string | undefined) { diff --git a/src/panels/VersionNote.tsx b/src/panels/VersionNote.tsx new file mode 100644 index 0000000..e64567f --- /dev/null +++ b/src/panels/VersionNote.tsx @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +import { useState } from 'react' +import { useStore } from '../store/store' + +/** + * Says out loud that IPD Studio ships often, and gives people the one action + * that guarantees they are on the newest build. + * + * A plain reload is not always enough: the app is an installed-capable PWA, so + * a service worker can keep serving the previous build from cache. This clears + * the caches and drops the worker before reloading, which is what "hard + * refresh" actually has to mean here. UpdateToast still offers the ordinary + * prompt when a new version is detected — this is the manual escape hatch. + */ +export async function forceRefresh(): Promise { + try { + if ('serviceWorker' in navigator) { + const regs = await navigator.serviceWorker.getRegistrations() + await Promise.all(regs.map((r) => r.unregister())) + } + if ('caches' in window) { + const keys = await caches.keys() + await Promise.all(keys.map((k) => caches.delete(k))) + } + } catch { + // A blocked cache or worker API must not stop the reload — that is the + // part that actually gets the user unstuck. + } + window.location.reload() +} + +const BLURB = + 'IPD Studio is under active development, so new versions ship often. ' + + 'You are prompted when one is ready; use this to check right now and reload.' + +export function VersionChip() { + const dirty = useStore((s) => s.dirty) + const [busy, setBusy] = useState(false) + + const refresh = () => { + if (dirty && !window.confirm('Reload to get the latest version? Your unsaved changes are autosaved, but any edit from the last moment may be lost.')) return + setBusy(true) + void forceRefresh() + } + + return ( + + ) +} + +/** Homepage strip. Same message, room to say it in full. */ +export function VersionBanner() { + return ( +
+ In active development + + v{__APP_VERSION__} — new versions ship often. If something looks out of date, reload the page + (or press {navigator.platform.toLowerCase().includes('mac') ? '⌘⇧R' : 'Ctrl+Shift+R'}). + + +
+ ) +} diff --git a/src/panels/WelcomeOverlay.tsx b/src/panels/WelcomeOverlay.tsx index 1608f1f..63dd9aa 100644 --- a/src/panels/WelcomeOverlay.tsx +++ b/src/panels/WelcomeOverlay.tsx @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + import { useState } from 'react' import Modal from './Modal' import { useStore } from '../store/store' @@ -25,8 +29,9 @@ export default function WelcomeOverlay() {

- Free, open-source P&ID drawing with real ISA symbols — and an HMI - simulator that brings your plant to life. Here's 2½ minutes of it: + P&ID drawing with real ISA symbols — and an HMI simulator that + brings your plant to life. Free for personal, academic, and nonprofit + use. Here's 2½ minutes of it: