vite-plugin-nora 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +26 -0
  3. package/README.md +113 -0
  4. package/licenses/geist-OFL.txt +93 -0
  5. package/licenses/lucide-ISC.txt +17 -0
  6. package/package.json +90 -0
  7. package/src/cli.js +101 -0
  8. package/src/client/App.jsx +216 -0
  9. package/src/client/canvas/Canvas.jsx +95 -0
  10. package/src/client/canvas/ErrorBoundary.jsx +56 -0
  11. package/src/client/canvas/Preview.jsx +70 -0
  12. package/src/client/canvas/Viewport.jsx +211 -0
  13. package/src/client/chords.js +38 -0
  14. package/src/client/css.d.ts +5 -0
  15. package/src/client/fonts.css +48 -0
  16. package/src/client/frame.css +107 -0
  17. package/src/client/frame.html +11 -0
  18. package/src/client/frame.jsx +73 -0
  19. package/src/client/index.html +12 -0
  20. package/src/client/main.jsx +10 -0
  21. package/src/client/open-folder.js +84 -0
  22. package/src/client/selection.js +22 -0
  23. package/src/client/styles.css +1264 -0
  24. package/src/client/sweep/SweepPanel.jsx +206 -0
  25. package/src/client/sweep/measure.js +230 -0
  26. package/src/client/sweep/report.js +54 -0
  27. package/src/client/sweep/run-sweep.js +185 -0
  28. package/src/client/toolbar/Picker.jsx +343 -0
  29. package/src/client/toolbar/Toolbar.jsx +231 -0
  30. package/src/client/toolbar/ViewportMenu.jsx +83 -0
  31. package/src/client/toolbar/bar-shape.js +402 -0
  32. package/src/client/toolbar/icons.jsx +129 -0
  33. package/src/client/toolbar/use-draggable-bar.js +204 -0
  34. package/src/client/viewports.js +43 -0
  35. package/src/client/virtual.d.ts +28 -0
  36. package/src/index.js +4 -0
  37. package/src/server/create-server.js +147 -0
  38. package/src/server/plugin.js +207 -0
  39. package/src/server/safe-path.js +42 -0
  40. package/src/server/scan.js +350 -0
  41. package/types/index.d.ts +3 -0
  42. package/types/server/create-server.d.ts +22 -0
  43. package/types/server/plugin.d.ts +20 -0
  44. package/types/server/safe-path.d.ts +25 -0
  45. package/types/server/scan.d.ts +106 -0
@@ -0,0 +1,107 @@
1
+ /* =========================================================================
2
+ Styles for the iframe document only.
3
+
4
+ Deliberately minimal: this document holds the user's component, so the less
5
+ we impose the closer the preview is to their real app. No box-sizing reset,
6
+ no inherited font, no margin collapsing tricks — only what the error surface
7
+ needs, all namespaced under .noraf-.
8
+
9
+ The bundled faces are imported here too, because an error card is *our*
10
+ chrome even though it renders in the user's document, and it looked
11
+ borrowed while the bar beside it did not. Nothing inherits them: the
12
+ @font-face rules are inert until one of the .noraf- rules below asks for them,
13
+ so a component that renders fine never decodes a byte. And the families are
14
+ namespaced ("Nora Geist"), so a project that loads the real Geist into its own
15
+ components is untouched by ours.
16
+ ========================================================================= */
17
+
18
+ @import "./fonts.css";
19
+
20
+ :root {
21
+ --noraf-bg: #f1f2f4;
22
+ --noraf-ink: #14171c;
23
+ --noraf-muted: #5c6672;
24
+ --noraf-line: #d8dce1;
25
+ --noraf-danger: #a32f2f;
26
+ --noraf-danger-bg: #fbeeee;
27
+ --noraf-mono: "Nora Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
28
+ --noraf-sans:
29
+ "Nora Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
30
+ }
31
+
32
+ :root[data-nora-theme="dark"] {
33
+ --noraf-bg: #101317;
34
+ --noraf-ink: #e6e9ed;
35
+ --noraf-muted: #8b95a2;
36
+ --noraf-line: #262d36;
37
+ --noraf-danger: #e07575;
38
+ --noraf-danger-bg: #2a1818;
39
+ }
40
+
41
+ html,
42
+ body {
43
+ margin: 0;
44
+ min-height: 100%;
45
+ background: var(--noraf-bg);
46
+ }
47
+
48
+ #nora-frame-root {
49
+ min-height: 100vh;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ padding: 1.5rem;
54
+ }
55
+
56
+ /* The stage itself adds nothing — no padding, no background — so a component
57
+ with its own margins behaves exactly as it does in the real app. */
58
+ .noraf-stage {
59
+ max-width: 100%;
60
+ }
61
+
62
+ .noraf-loading {
63
+ font-family: var(--noraf-mono);
64
+ font-size: 0.78rem;
65
+ color: var(--noraf-muted);
66
+ }
67
+
68
+ .noraf-error {
69
+ box-sizing: border-box;
70
+ max-width: 60ch;
71
+ border: 1px solid var(--noraf-danger);
72
+ background: var(--noraf-danger-bg);
73
+ border-radius: 8px;
74
+ padding: 1rem 1.15rem;
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: 0.6rem;
78
+ font-family: var(--noraf-sans);
79
+ }
80
+
81
+ .noraf-error-head {
82
+ font-family: var(--noraf-sans);
83
+ font-size: 0.86rem;
84
+ letter-spacing: -0.01em;
85
+ font-weight: 600;
86
+ color: var(--noraf-danger);
87
+ }
88
+
89
+ .noraf-error-msg {
90
+ margin: 0;
91
+ font-family: var(--noraf-mono);
92
+ font-size: 0.75rem;
93
+ line-height: 1.6;
94
+ color: var(--noraf-ink);
95
+ white-space: pre-wrap;
96
+ word-break: break-word;
97
+ max-height: 12rem;
98
+ overflow: auto;
99
+ }
100
+
101
+ .noraf-error-hint {
102
+ font-size: 0.84rem;
103
+ line-height: 1.55;
104
+ color: var(--noraf-muted);
105
+ border-top: 1px dashed var(--noraf-line);
106
+ padding-top: 0.6rem;
107
+ }
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>nora frame</title>
6
+ </head>
7
+ <body>
8
+ <div id="nora-frame-root"></div>
9
+ <script type="module" src="__CP_ENTRY__"></script>
10
+ </body>
11
+ </html>
@@ -0,0 +1,73 @@
1
+ import { useEffect, useState } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { entries, config } from "virtual:nora/registry";
4
+ import { Preview } from "./canvas/Preview.jsx";
5
+ import { matchChord } from "./chords.js";
6
+ import "./frame.css";
7
+
8
+ const CHANNEL = "nora";
9
+
10
+ /**
11
+ * The document inside the iframe. It holds the whole render path — the config
12
+ * wrapper, the error boundary, and the component — so that everything the
13
+ * component can observe about its environment (viewport width, media queries,
14
+ * `vw` units, `window.innerWidth`) is the size the toolbar asked for.
15
+ *
16
+ * The shell talks to it over postMessage rather than by swapping the iframe
17
+ * src, so switching components does not tear down and rebuild the document.
18
+ */
19
+ function Frame() {
20
+ const [selectedId, setSelectedId] = useState(null);
21
+ const [theme, setTheme] = useState("light");
22
+
23
+ useEffect(() => {
24
+ const onMessage = (event) => {
25
+ if (event.origin !== location.origin) return;
26
+ const msg = event.data;
27
+ if (!msg || msg.source !== CHANNEL) return;
28
+ if (msg.type === "select") {
29
+ setSelectedId(msg.id ?? null);
30
+ if (msg.theme) setTheme(msg.theme);
31
+ }
32
+ };
33
+ window.addEventListener("message", onMessage);
34
+
35
+ // Clicking anything in the preview moves focus into this iframe, and from
36
+ // then on every keystroke lands here instead of on the shell — so ⌘K and
37
+ // friends silently stop working the moment you interact with the component
38
+ // you are inspecting. Forward the shortcuts we own back up.
39
+ //
40
+ // Deliberately narrow: only our own chords. Plain keys stay in the frame so
41
+ // a previewed text field, slider, or menu still behaves normally.
42
+ const onKey = (event) => {
43
+ const chord = matchChord(event);
44
+ if (!chord) return;
45
+ event.preventDefault();
46
+ window.parent?.postMessage({ source: CHANNEL, type: "shortcut", chord }, location.origin);
47
+ };
48
+ window.addEventListener("keydown", onKey);
49
+
50
+ // Tell the shell we are mounted and ready to be told what to render.
51
+ // The shell also posts on iframe load, so this is belt and braces for HMR.
52
+ window.parent?.postMessage({ source: CHANNEL, type: "ready" }, location.origin);
53
+
54
+ return () => {
55
+ window.removeEventListener("message", onMessage);
56
+ window.removeEventListener("keydown", onKey);
57
+ };
58
+ }, []);
59
+
60
+ // The one thing a previewed component can rely on: the active theme, named on
61
+ // the root of the document it renders in. frame.css keys its own dark palette
62
+ // off the same attribute, and so can yours.
63
+ useEffect(() => {
64
+ document.documentElement.dataset.noraTheme = theme;
65
+ }, [theme]);
66
+
67
+ const entry = entries.find((e) => e.id === selectedId) ?? null;
68
+ if (!entry) return null;
69
+
70
+ return <Preview entry={entry} wrapper={config?.wrapper} />;
71
+ }
72
+
73
+ createRoot(document.getElementById("nora-frame-root")).render(<Frame />);
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>nora</title>
7
+ </head>
8
+ <body>
9
+ <div id="nora-root"></div>
10
+ <script type="module" src="__CP_ENTRY__"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App.jsx";
4
+ import "./styles.css";
5
+
6
+ createRoot(document.getElementById("nora-root")).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ );
@@ -0,0 +1,84 @@
1
+ import { writeSelectionToUrl } from "./selection.js";
2
+
3
+ const REOPEN_KEY = "nora:reopen-picker";
4
+
5
+ /**
6
+ * Point nora at a folder.
7
+ *
8
+ * This used to be half an operation in two places: the picker ran the fetch and
9
+ * handed back the folder the server confirmed, and the shell reloaded without
10
+ * accepting it. The value crossed a seam and was dropped on the other side.
11
+ *
12
+ * One module owns the whole thing now, and it contains no React, so it can be
13
+ * exercised without a renderer.
14
+ *
15
+ * **It does not return on success.** Changing folders rebuilds the registry on
16
+ * the server, which ends this document rather than updating it, so the only
17
+ * outcomes are "the page is being replaced" and "it threw". Callers should
18
+ * treat a rejection as the sole case worth handling, and leave any pending
19
+ * state set: there is no render after this to clear it in.
20
+ *
21
+ * @param {string} dir folder to scan, relative to the project root
22
+ * @returns {Promise<never>}
23
+ */
24
+ export async function openFolder(dir) {
25
+ // Raised before the request, not after. A successful scan makes the server
26
+ // tell every client to reload, and that broadcast can reach this one while it
27
+ // is still awaiting the response — so the flag has to already be set, or the
28
+ // picker would vanish on the way through.
29
+ setFlag(true);
30
+
31
+ try {
32
+ const res = await fetch("/__nora/scan", {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({ path: dir }),
36
+ });
37
+ const json = await res.json();
38
+ if (json?.error) throw new Error(json.error);
39
+ } catch (err) {
40
+ setFlag(false); // no reload is coming; don't leave it armed for the next one
41
+ throw err;
42
+ }
43
+
44
+ // The selection belonged to the folder we are leaving.
45
+ writeSelectionToUrl(null);
46
+
47
+ location.reload();
48
+ return await new Promise(() => {});
49
+ }
50
+
51
+ /** @param {boolean} on */
52
+ function setFlag(on) {
53
+ try {
54
+ if (on) sessionStorage.setItem(REOPEN_KEY, "1");
55
+ else sessionStorage.removeItem(REOPEN_KEY);
56
+ } catch {
57
+ /* storage disabled — the picker just won't reopen by itself */
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Did this document begin with someone opening a folder?
63
+ *
64
+ * The flag is the only thing that survives the reload above, and it is what
65
+ * keeps that reload from being felt: the picker was open before and is open
66
+ * after, now listing the folder just opened.
67
+ *
68
+ * Consumed on read, so call it exactly once, at module scope. A second call
69
+ * always misses, and a call inside a component would miss on every render after
70
+ * the first.
71
+ *
72
+ * @returns {boolean}
73
+ */
74
+ export function consumeReopenFlag() {
75
+ try {
76
+ if (sessionStorage.getItem(REOPEN_KEY) === "1") {
77
+ sessionStorage.removeItem(REOPEN_KEY);
78
+ return true;
79
+ }
80
+ } catch {
81
+ /* storage disabled — the handoff just doesn't happen */
82
+ }
83
+ return false;
84
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The selected component lives in the URL, so a reload, an HMR update and a
3
+ * pasted link all keep your place.
4
+ *
5
+ * Its own module because two things need it and they are not in the same tree:
6
+ * the shell reads it on boot, and opening a folder clears it on the way out
7
+ * (see open-folder.js). Duplicating four lines of URLSearchParams across both
8
+ * is how the two drift.
9
+ */
10
+
11
+ /** @returns {string | null} */
12
+ export function readSelectionFromUrl() {
13
+ return new URLSearchParams(location.search).get("c");
14
+ }
15
+
16
+ /** @param {string | null} id */
17
+ export function writeSelectionToUrl(id) {
18
+ const url = new URL(location.href);
19
+ if (id) url.searchParams.set("c", id);
20
+ else url.searchParams.delete("c");
21
+ history.replaceState(null, "", url);
22
+ }