create-obsidian-arrow 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/cli/create.mjs +65 -0
- package/cli/detect-pm.mjs +20 -0
- package/cli/lib.mjs +117 -0
- package/cli/refresh.mjs +65 -0
- package/index.mjs +47 -204
- package/package.json +11 -2
- package/template/.husky/pre-commit +3 -2
- package/template/AGENTS.md +58 -12
- package/template/README.md +67 -31
- package/template/_gitignore +4 -1
- package/template/biome.json +7 -1
- package/template/docs/prompts/agent-setup.md +24 -20
- package/template/docs/prompts/update-existing.md +3 -3
- package/template/docs/workflow.md +11 -7
- package/template/package.json +15 -14
- package/template/src/components/DiffViewer/DiffViewer.css +41 -0
- package/template/src/components/DiffViewer/DiffViewer.ts +55 -0
- package/template/src/components/EmptyState/EmptyState.css +5 -5
- package/template/src/components/EmptyState/EmptyState.ts +5 -9
- package/template/src/utilities.css +259 -1
- package/template/src/views/DiffViewer/DiffViewerView.css +42 -0
- package/template/src/views/DiffViewer/DiffViewerView.ts +53 -0
- package/template/src/views/ExampleView/ExampleView.ts +92 -0
- package/template/stories/components/ComponentShell.stories.ts +28 -0
- package/template/stories/components/EmptyState.stories.ts +1 -0
- package/template/stories/components/LoadingState.stories.ts +1 -0
- package/template/stories/components/Toggle.stories.ts +50 -0
- package/template/stories/views/DiffViewer/DiffViewer.stories.ts +94 -0
- package/template/stories/views/EditorView.stories.ts +55 -0
- package/template/stories/views/ExampleView/ExampleView.stories.ts +15 -0
- package/template/stories/views/PanelView.stories.ts +61 -0
- package/template/stories/views/SettingsPanel/SettingsPanel.stories.ts +14 -0
- package/template/test/css-structure.test.mjs +112 -0
- package/template/test/template-footguns.test.mjs +85 -6
- package/template/test/viewer-stories.test.mjs +12 -0
- package/template/tools/router/client.ts +26 -4
- package/template/tools/router/routeToPage.ts +29 -13
- package/template/tools/sandbox/frame.ts +7 -27
- package/template/tools/sandbox/home.ts +6 -11
- package/template/tools/sandbox/layout.ts +24 -2
- package/template/tools/sandbox/sandbox.css +188 -226
- package/template/tools/sandbox/shell.ts +2 -2
- package/template/tools/sandbox/toolbar.ts +20 -9
- package/template/tools/viewer/ClassesPage.ts +7 -7
- package/template/tools/viewer/ComponentsIndex.ts +3 -3
- package/template/tools/viewer/StoryPage.ts +53 -40
- package/template/tools/viewer/TokensPage.ts +10 -10
- package/template/tools/viewer/ViewsIndex.ts +66 -0
- package/template/tools/viewer/discovery.ts +2 -0
- package/template/tools/viewer/obsidian-classes.ts +1 -1
- package/template/tools/viewer/sidebar.ts +27 -38
- package/template/tools/viewer/stories.ts +16 -2
- package/template/.github/workflows/ci.yml +0 -36
- package/template/pnpm-lock.yaml +0 -1608
- package/template/scripts/create-component.mjs +0 -101
- package/template/scripts/create-view.mjs +0 -75
- package/template/src/components/DiffViewer.ts +0 -42
- package/template/stories/DiffViewer.stories.ts +0 -75
- package/template/stories/SettingsPanel.stories.ts +0 -11
- package/template/stories/Toggle.stories.ts +0 -28
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { DiffViewerView } from "../../../src/views/DiffViewer/DiffViewerView";
|
|
2
|
+
import { defineStories } from "../../../tools/viewer/stories";
|
|
3
|
+
|
|
4
|
+
const ORIGINAL = `---
|
|
5
|
+
title: Meeting Notes
|
|
6
|
+
status: draft
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Team Standup
|
|
10
|
+
|
|
11
|
+
Quick notes from today's standup.
|
|
12
|
+
|
|
13
|
+
## Done
|
|
14
|
+
|
|
15
|
+
- Reviewed the PR for the search panel
|
|
16
|
+
- Fixed the token filter in the reference viewer
|
|
17
|
+
|
|
18
|
+
## In Progress
|
|
19
|
+
|
|
20
|
+
- Arrow component for the diff viewer
|
|
21
|
+
- Documentation updates
|
|
22
|
+
|
|
23
|
+
## Notes
|
|
24
|
+
|
|
25
|
+
See the project board for full task breakdown.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const MODIFIED = `---
|
|
29
|
+
title: Meeting Notes
|
|
30
|
+
status: complete
|
|
31
|
+
tags: [standup, team]
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
# Team Standup — 2026-07-04
|
|
35
|
+
|
|
36
|
+
Notes from today's standup.
|
|
37
|
+
|
|
38
|
+
## Done
|
|
39
|
+
|
|
40
|
+
- Reviewed and merged the PR for the search panel
|
|
41
|
+
- Fixed the token filter in the reference viewer
|
|
42
|
+
- Added DiffViewer view to the sandbox
|
|
43
|
+
|
|
44
|
+
## In Progress
|
|
45
|
+
|
|
46
|
+
- Documentation updates
|
|
47
|
+
- Editor pane integration
|
|
48
|
+
|
|
49
|
+
## Notes
|
|
50
|
+
|
|
51
|
+
See the project board for full task breakdown.
|
|
52
|
+
Next standup: Thursday.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const CODE_ORIGINAL = `function greet(name: string): string {
|
|
56
|
+
\treturn "Hello, " + name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = greet("world");
|
|
60
|
+
console.log(result);
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const CODE_MODIFIED = `function greet(name: string, greeting = "Hello"): string {
|
|
64
|
+
\treturn \`\${greeting}, \${name}!\`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result = greet("world", "Hi");
|
|
68
|
+
console.log(result);
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
export default defineStories({
|
|
72
|
+
title: "Editor / DiffViewer",
|
|
73
|
+
description:
|
|
74
|
+
"Full-pane diff editor using CodeMirror 6 MergeView. Header shows file labels and accept/reject actions. Body fills the remaining height — CM6 manages internal scrolling.",
|
|
75
|
+
status: "live",
|
|
76
|
+
kind: "view",
|
|
77
|
+
componentPath: "src/views/DiffViewer/DiffViewerView.ts",
|
|
78
|
+
variants: {
|
|
79
|
+
markdown: () =>
|
|
80
|
+
DiffViewerView({
|
|
81
|
+
original: ORIGINAL,
|
|
82
|
+
modified: MODIFIED,
|
|
83
|
+
originalLabel: "notes-draft.md",
|
|
84
|
+
modifiedLabel: "notes-revised.md",
|
|
85
|
+
}),
|
|
86
|
+
code: () =>
|
|
87
|
+
DiffViewerView({
|
|
88
|
+
original: CODE_ORIGINAL,
|
|
89
|
+
modified: CODE_MODIFIED,
|
|
90
|
+
originalLabel: "utils.ts (original)",
|
|
91
|
+
modifiedLabel: "utils.ts (modified)",
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { html } from "@arrow-js/core";
|
|
2
|
+
import { defineStories } from "../../tools/viewer/stories";
|
|
3
|
+
|
|
4
|
+
export default defineStories({
|
|
5
|
+
title: "Views / Editor View",
|
|
6
|
+
description:
|
|
7
|
+
"Note/document view at readable line width. Full-bleed header + footer with the body content capped at --file-line-width via oas-readable-width. Copy this for any editor- or reader-style view; scaffold with `pnpm create:view <Name> --editor`.",
|
|
8
|
+
status: "live",
|
|
9
|
+
kind: "view",
|
|
10
|
+
surface: "editor",
|
|
11
|
+
componentPath: "src/utilities.css",
|
|
12
|
+
variants: {
|
|
13
|
+
default: () => html`
|
|
14
|
+
<div class="oas-shell-view" style="height: 420px; border: 1px dashed var(--background-modifier-border);">
|
|
15
|
+
<div class="oas-shell-view-header" style="padding: var(--size-4-2) var(--size-4-3); background: var(--background-secondary); border-bottom: 1px solid var(--background-modifier-border);">
|
|
16
|
+
Editor header (title, actions) — spans the full pane
|
|
17
|
+
</div>
|
|
18
|
+
<div class="oas-shell-view-body">
|
|
19
|
+
<div class="oas-readable-width">
|
|
20
|
+
<h1>Document title</h1>
|
|
21
|
+
<p>
|
|
22
|
+
The body content is capped at Obsidian's readable line width
|
|
23
|
+
(<code>--file-line-width</code>, 700px) and centered — the same
|
|
24
|
+
measure the markdown editor uses. Header and footer stay full-bleed.
|
|
25
|
+
</p>
|
|
26
|
+
${Array.from({ length: 4 }, (_, i) =>
|
|
27
|
+
html`<p>
|
|
28
|
+
Paragraph ${i + 1}. Long-form prose stays comfortable to read
|
|
29
|
+
because the line length never exceeds the readable measure, no
|
|
30
|
+
matter how wide the pane is dragged. Resize the panel to see the
|
|
31
|
+
column stay put while the chrome fills the width.
|
|
32
|
+
</p>`.key(i)
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="oas-shell-view-footer" style="padding: var(--size-4-2) var(--size-4-3);">
|
|
37
|
+
Editor footer (word count, status) — spans the full pane
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
`,
|
|
41
|
+
body_only: () => html`
|
|
42
|
+
<div class="oas-shell-view" style="height: 420px; border: 1px dashed var(--background-modifier-border);">
|
|
43
|
+
<div class="oas-shell-view-body">
|
|
44
|
+
<div class="oas-readable-width">
|
|
45
|
+
<h1>Reader view</h1>
|
|
46
|
+
<p>
|
|
47
|
+
Body only — no header or footer. Content is centered at readable
|
|
48
|
+
line width, ideal for a distraction-free reader or preview pane.
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
`,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ExampleView } from "../../../src/views/ExampleView/ExampleView";
|
|
2
|
+
import { defineStories } from "../../../tools/viewer/stories";
|
|
3
|
+
|
|
4
|
+
export default defineStories({
|
|
5
|
+
title: "Example / ExampleView",
|
|
6
|
+
description:
|
|
7
|
+
"Reference shell for a new view. Duplicate this folder, rename ExampleView → YourView, and replace the stub content. The oas-shell-* classes handle all layout.",
|
|
8
|
+
status: "live",
|
|
9
|
+
kind: "view",
|
|
10
|
+
componentPath: "src/views/ExampleView/ExampleView.ts",
|
|
11
|
+
variants: {
|
|
12
|
+
with_items: () => ExampleView(),
|
|
13
|
+
empty: () => ExampleView([]),
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { html } from "@arrow-js/core";
|
|
2
|
+
import { defineStories } from "../../tools/viewer/stories";
|
|
3
|
+
|
|
4
|
+
export default defineStories({
|
|
5
|
+
title: "Views / Panel View",
|
|
6
|
+
description:
|
|
7
|
+
"Standard view structure: header, scrollable body, pinned footer. Copy this pattern for any new view — duplicate the view folder and change names and imports.",
|
|
8
|
+
status: "live",
|
|
9
|
+
kind: "view",
|
|
10
|
+
componentPath: "src/utilities.css",
|
|
11
|
+
variants: {
|
|
12
|
+
default: () => html`
|
|
13
|
+
<div class="oas-shell-view" style="height: 320px; border: 1px dashed var(--background-modifier-border);">
|
|
14
|
+
<div class="oas-shell-view-header" style="padding: var(--size-4-2) var(--size-4-3); background: var(--background-secondary); border-bottom: 1px solid var(--background-modifier-border);">
|
|
15
|
+
View header (breadcrumbs, actions)
|
|
16
|
+
</div>
|
|
17
|
+
<div class="oas-shell-view-body" style="padding: var(--size-4-3);">
|
|
18
|
+
<p>Scrollable content area. Add more items here to see scrolling.</p>
|
|
19
|
+
${Array.from({ length: 8 }, (_, i) =>
|
|
20
|
+
html`<div class="setting-item"><div class="setting-item-info"><div class="setting-item-name">Item ${i + 1}</div></div></div>`.key(
|
|
21
|
+
i
|
|
22
|
+
)
|
|
23
|
+
)}
|
|
24
|
+
</div>
|
|
25
|
+
<div class="oas-shell-view-footer" style="padding: var(--size-4-2) var(--size-4-3);">
|
|
26
|
+
View footer (status bar, actions)
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
`,
|
|
30
|
+
body_only: () => html`
|
|
31
|
+
<div class="oas-shell-view" style="height: 320px; border: 1px dashed var(--background-modifier-border);">
|
|
32
|
+
<div class="oas-shell-view-body" style="padding: var(--size-4-3);">
|
|
33
|
+
<p>View with body only — no header or footer.</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
`,
|
|
37
|
+
expanding_footer: {
|
|
38
|
+
render: () => html`
|
|
39
|
+
<div class="oas-shell-view" style="height: 320px; border: 1px dashed var(--background-modifier-border);">
|
|
40
|
+
<div class="oas-shell-view-header oas-flex oas-items-center" style="padding: 0 var(--size-4-3); background: var(--background-secondary); border-bottom: 1px solid var(--background-modifier-border);">
|
|
41
|
+
Header — pinned top at min-height (${"40px"})
|
|
42
|
+
</div>
|
|
43
|
+
<div class="oas-shell-view-body" style="padding: var(--size-4-3);">
|
|
44
|
+
<p>Body fills the space between and scrolls. The footer below holds a
|
|
45
|
+
contained component and expands past its min-height to fit it, staying
|
|
46
|
+
pinned to the bottom.</p>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="oas-shell-view-footer oas-flex oas-flex-col" style="gap: var(--size-4-2); padding: var(--size-4-3); background: var(--background-secondary);">
|
|
49
|
+
<textarea class="oas-w-full" rows="3" placeholder="A composer that grows the footer…"></textarea>
|
|
50
|
+
<div class="oas-flex oas-items-center oas-justify-between">
|
|
51
|
+
<span class="setting-item-description">Footer expands for its contents</span>
|
|
52
|
+
<button class="mod-cta">Send</button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
`,
|
|
57
|
+
notes:
|
|
58
|
+
"Footer holds a multi-row component and expands past its 40px min-height while staying pinned to the bottom; the body shrinks and scrolls to give it room.",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SettingsPanel } from "../../../src/components/SettingsPanel";
|
|
2
|
+
import { defineStories } from "../../../tools/viewer/stories";
|
|
3
|
+
|
|
4
|
+
export default defineStories({
|
|
5
|
+
title: "Settings / SettingsPanel",
|
|
6
|
+
description:
|
|
7
|
+
"Full settings view with vertical tabs, feature toggles, a keyed list, and an async boundary() section. The canonical reference for Obsidian settings-style views.",
|
|
8
|
+
status: "live",
|
|
9
|
+
kind: "view",
|
|
10
|
+
componentPath: "src/components/SettingsPanel.ts",
|
|
11
|
+
variants: {
|
|
12
|
+
default: () => SettingsPanel(),
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CSS structure integrity guards.
|
|
9
|
+
*
|
|
10
|
+
* The sandbox has three CSS layers:
|
|
11
|
+
* oasbox-* sandbox chrome only (tools/sandbox/sandbox.css) — never port to plugin
|
|
12
|
+
* oas-* portable utilities (src/utilities.css) — ships with component ports
|
|
13
|
+
* oas-diff-viewer co-located with DiffViewer component (src/components/DiffViewer/DiffViewer.css)
|
|
14
|
+
*
|
|
15
|
+
* These tests ensure classes live in the right layer after the overhaul.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
|
|
20
|
+
const utilitiesCSS = fs.readFileSync(path.join(root, "src/utilities.css"), "utf8");
|
|
21
|
+
const sandboxCSS = fs.readFileSync(path.join(root, "tools/sandbox/sandbox.css"), "utf8");
|
|
22
|
+
const diffViewerCSS = fs.readFileSync(
|
|
23
|
+
path.join(root, "src/components/DiffViewer/DiffViewer.css"),
|
|
24
|
+
"utf8"
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Strip block comments from CSS before checking selectors.
|
|
28
|
+
function stripComments(css) {
|
|
29
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── utilities.css must contain portable classes ──────────────────────────────
|
|
33
|
+
|
|
34
|
+
test("utilities.css contains portable badge classes", () => {
|
|
35
|
+
assert.ok(utilitiesCSS.includes(".oas-badge {"), "missing .oas-badge");
|
|
36
|
+
assert.ok(utilitiesCSS.includes(".oas-badge.is-live"), "missing .oas-badge.is-live");
|
|
37
|
+
assert.ok(utilitiesCSS.includes(".oas-badge.is-draft"), "missing .oas-badge.is-draft");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("utilities.css contains portable card classes", () => {
|
|
41
|
+
assert.ok(utilitiesCSS.includes(".oas-card {"), "missing .oas-card");
|
|
42
|
+
assert.ok(utilitiesCSS.includes(".oas-card-header"), "missing .oas-card-header");
|
|
43
|
+
assert.ok(utilitiesCSS.includes(".oas-card-body"), "missing .oas-card-body");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("utilities.css contains view/component shell classes", () => {
|
|
47
|
+
assert.ok(utilitiesCSS.includes(".oas-shell-view {"), "missing .oas-shell-view");
|
|
48
|
+
assert.ok(utilitiesCSS.includes(".oas-shell-view-body {"), "missing .oas-shell-view-body");
|
|
49
|
+
assert.ok(utilitiesCSS.includes(".oas-shell-view-header {"), "missing .oas-shell-view-header");
|
|
50
|
+
assert.ok(utilitiesCSS.includes(".oas-shell-panel {"), "missing .oas-shell-panel");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("utilities.css contains EmptyState classes", () => {
|
|
54
|
+
// EmptyState CSS is co-located (src/components/EmptyState/EmptyState.css),
|
|
55
|
+
// but uses oas- prefix so it ports with the component. Verify it exists.
|
|
56
|
+
const emptyStateCSS = fs.readFileSync(
|
|
57
|
+
path.join(root, "src/components/EmptyState/EmptyState.css"),
|
|
58
|
+
"utf8"
|
|
59
|
+
);
|
|
60
|
+
assert.ok(emptyStateCSS.includes(".oas-empty-state {"), "missing .oas-empty-state");
|
|
61
|
+
assert.ok(emptyStateCSS.includes(".oas-empty-title {"), "missing .oas-empty-title");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── DiffViewer.css must be co-located ────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
test("DiffViewer.css is co-located and contains .oas-diff-viewer", () => {
|
|
67
|
+
assert.ok(
|
|
68
|
+
diffViewerCSS.includes(".oas-diff-viewer {"),
|
|
69
|
+
"missing .oas-diff-viewer in DiffViewer.css"
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── sandbox.css must not contain portable classes (they were moved) ───────────
|
|
74
|
+
|
|
75
|
+
test("sandbox.css does not define .oas-badge (moved to utilities.css)", () => {
|
|
76
|
+
const stripped = stripComments(sandboxCSS);
|
|
77
|
+
assert.ok(
|
|
78
|
+
!stripped.includes(".oas-badge"),
|
|
79
|
+
".oas-badge must not appear in sandbox.css — it lives in utilities.css"
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("sandbox.css does not define .oas-card (moved to utilities.css)", () => {
|
|
84
|
+
const stripped = stripComments(sandboxCSS);
|
|
85
|
+
assert.ok(
|
|
86
|
+
!stripped.includes(".oas-card"),
|
|
87
|
+
".oas-card must not appear in sandbox.css — it lives in utilities.css"
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("sandbox.css does not define .oas-diff-viewer (moved to DiffViewer.css)", () => {
|
|
92
|
+
const stripped = stripComments(sandboxCSS);
|
|
93
|
+
assert.ok(
|
|
94
|
+
!stripped.includes(".oas-diff-viewer"),
|
|
95
|
+
".oas-diff-viewer must not appear in sandbox.css — it lives in DiffViewer.css"
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── sandbox.css must use oasbox- prefix for all chrome class selectors ────────
|
|
100
|
+
|
|
101
|
+
test("sandbox.css has no .oas- class selectors (all renamed to oasbox- or moved out)", () => {
|
|
102
|
+
const stripped = stripComments(sandboxCSS);
|
|
103
|
+
// Match lines that start a new rule with .oas- (not .oasbox-)
|
|
104
|
+
const violations = stripped
|
|
105
|
+
.split("\n")
|
|
106
|
+
.filter((line) => /^\s*\.oas-/.test(line) && !/^\s*\.oasbox-/.test(line));
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
violations,
|
|
109
|
+
[],
|
|
110
|
+
`sandbox.css chrome classes must use oasbox- prefix. oas-* is for portable utilities.\n${violations.join("\n")}`
|
|
111
|
+
);
|
|
112
|
+
});
|
|
@@ -12,9 +12,13 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
* HTML comment should appear in any of them. (Use JS // comments instead.)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
const srcDir = path.join(root, "src");
|
|
17
|
+
// tools/ also contains Arrow html`` templates (viewer, router, sandbox chrome)
|
|
18
|
+
const toolsDir = path.join(root, "tools");
|
|
16
19
|
|
|
17
20
|
function tsFiles(dir) {
|
|
21
|
+
if (!fs.existsSync(dir)) return [];
|
|
18
22
|
const out = [];
|
|
19
23
|
for (const name of fs.readdirSync(dir)) {
|
|
20
24
|
const full = path.join(dir, name);
|
|
@@ -25,14 +29,16 @@ function tsFiles(dir) {
|
|
|
25
29
|
return out;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
const allTemplateDirs = [srcDir, toolsDir];
|
|
33
|
+
|
|
28
34
|
test("no literal HTML comments in Arrow template modules", () => {
|
|
29
|
-
const offenders =
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const offenders = allTemplateDirs
|
|
36
|
+
.flatMap(tsFiles)
|
|
37
|
+
.filter((file) => fs.readFileSync(file, "utf8").includes("<!--"));
|
|
32
38
|
assert.deepEqual(
|
|
33
|
-
offenders.map((f) => path.relative(
|
|
39
|
+
offenders.map((f) => path.relative(root, f)),
|
|
34
40
|
[],
|
|
35
|
-
"HTML comments break Arrow templates —
|
|
41
|
+
"HTML comments break Arrow templates — use JS // comments instead (<!-- --> inflates expression slot count)"
|
|
36
42
|
);
|
|
37
43
|
});
|
|
38
44
|
|
|
@@ -56,3 +62,76 @@ test("inline @event handlers type the param as Event, not a narrowed subtype", (
|
|
|
56
62
|
"Arrow @event handlers must use (e: Event), not a narrowed subtype (e.g. MouseEvent); narrow inside the handler instead"
|
|
57
63
|
);
|
|
58
64
|
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Footgun #4 (type-level): `as unknown as ArrowTemplate` or
|
|
68
|
+
* `as unknown as ArrowExpression` double-cast. This silences TypeScript but
|
|
69
|
+
* Arrow.js 1.x does NOT support raw Node insertion in template expressions —
|
|
70
|
+
* it falls through to createTextNode(String(value)), rendering "[object HTMLDivElement]".
|
|
71
|
+
* Use queueMicrotask to mount imperative widgets (see DiffViewer.ts for the pattern).
|
|
72
|
+
*/
|
|
73
|
+
test("no `as unknown as Arrow*` double-cast outside DiffViewer", () => {
|
|
74
|
+
const offenders = tsFiles(srcDir).filter((file) =>
|
|
75
|
+
/as unknown as Arrow(?:Template|Expression)/.test(fs.readFileSync(file, "utf8"))
|
|
76
|
+
);
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
offenders.map((f) => path.relative(srcDir, f)),
|
|
79
|
+
[],
|
|
80
|
+
"`as unknown as ArrowTemplate/ArrowExpression` renders as [object Object] — use queueMicrotask to mount imperative widgets instead (see DiffViewer.ts)"
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Footgun #5 (data): story variant render functions must not call Date.now().
|
|
86
|
+
* Story variants must be deterministic — static mock data only. Agents often
|
|
87
|
+
* inject Date.now() thinking it's fine for timestamps; it isn't: the viewer
|
|
88
|
+
* shows a different value on every render and differs from what was tested.
|
|
89
|
+
*/
|
|
90
|
+
const storiesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "stories");
|
|
91
|
+
|
|
92
|
+
function storiesFiles(dir) {
|
|
93
|
+
if (!fs.existsSync(dir)) return [];
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const name of fs.readdirSync(dir)) {
|
|
96
|
+
const full = path.join(dir, name);
|
|
97
|
+
const stat = fs.statSync(full);
|
|
98
|
+
if (stat.isDirectory()) out.push(...storiesFiles(full));
|
|
99
|
+
else if (name.endsWith(".stories.ts")) out.push(full);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test("story variants must not call Date.now()", () => {
|
|
105
|
+
const offenders = storiesFiles(storiesDir).filter((file) =>
|
|
106
|
+
fs.readFileSync(file, "utf8").includes("Date.now()")
|
|
107
|
+
);
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
offenders.map((f) => path.relative(storiesDir, f)),
|
|
110
|
+
[],
|
|
111
|
+
"Story variants must not call Date.now() — use static mock data from mock-data.ts instead"
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Footgun #6 (story metadata): status must be "live" or "draft" if set.
|
|
117
|
+
* An unknown status silently causes the badge to be omitted, making the
|
|
118
|
+
* story appear as a draft without explanation.
|
|
119
|
+
*/
|
|
120
|
+
const VALID_STATUS = new Set(["live", "draft"]);
|
|
121
|
+
|
|
122
|
+
test("story status field must be 'live', 'draft', or absent", () => {
|
|
123
|
+
const offenders = [];
|
|
124
|
+
for (const file of storiesFiles(storiesDir)) {
|
|
125
|
+
const src = fs.readFileSync(file, "utf8");
|
|
126
|
+
// Match status: "something" — catch values other than live/draft
|
|
127
|
+
const match = src.match(/\bstatus:\s*["']([^"']+)["']/);
|
|
128
|
+
if (match && !VALID_STATUS.has(match[1])) {
|
|
129
|
+
offenders.push(`${path.relative(storiesDir, file)}: status "${match[1]}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
assert.deepEqual(
|
|
133
|
+
offenders,
|
|
134
|
+
[],
|
|
135
|
+
`Story status must be "live" or "draft" (or omitted). Invalid:\n${offenders.join("\n")}`
|
|
136
|
+
);
|
|
137
|
+
});
|
|
@@ -55,6 +55,18 @@ test("validateStoryDef rejects invalid kind", () => {
|
|
|
55
55
|
assert.match(r.reason, /kind/);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
test("validateStoryDef accepts surface: panel and surface: editor", () => {
|
|
59
|
+
const base = { variants: { default: () => {} } };
|
|
60
|
+
assert.deepEqual(validateStoryDef({ ...base, surface: "panel" }), { ok: true });
|
|
61
|
+
assert.deepEqual(validateStoryDef({ ...base, surface: "editor" }), { ok: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("validateStoryDef rejects invalid surface", () => {
|
|
65
|
+
const r = validateStoryDef({ variants: { default: () => {} }, surface: "wide" });
|
|
66
|
+
assert.equal(r.ok, false);
|
|
67
|
+
assert.match(r.reason, /surface/);
|
|
68
|
+
});
|
|
69
|
+
|
|
58
70
|
test("validateStoryDef accepts decorator function", () => {
|
|
59
71
|
const r = validateStoryDef({
|
|
60
72
|
variants: { default: () => {} },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { html } from "@arrow-js/core";
|
|
2
2
|
import type { ArrowExpression } from "@arrow-js/core";
|
|
3
3
|
import { Frame } from "../sandbox/frame";
|
|
4
|
+
import { applyDefaultWidth, layoutState, pageState, startResize } from "../sandbox/layout";
|
|
4
5
|
import { Shell } from "../sandbox/shell";
|
|
5
6
|
import type { Page } from "./routeToPage";
|
|
6
7
|
import { routeToPage } from "./routeToPage";
|
|
@@ -30,8 +31,24 @@ function getNavigation(): NavigationLike | undefined {
|
|
|
30
31
|
return (window as unknown as { navigation?: NavigationLike }).navigation;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** Component canvas: width controlled by panel slider, content centered. */
|
|
33
35
|
function ComponentCanvas(content: ArrowExpression): ArrowExpression {
|
|
34
|
-
return html
|
|
36
|
+
return html`
|
|
37
|
+
<div class="oasbox-component-canvas" style="${() => `width:${layoutState.width}px`}">
|
|
38
|
+
${content}
|
|
39
|
+
<div class="oasbox-resize-handle" aria-hidden="true" @mousedown="${startResize}"></div>
|
|
40
|
+
</div>
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** View canvas: width controlled by panel slider, content fills full height. */
|
|
45
|
+
function ViewCanvas(content: ArrowExpression): ArrowExpression {
|
|
46
|
+
return html`
|
|
47
|
+
<div class="oasbox-view-canvas" style="${() => `width:${layoutState.width}px`}">
|
|
48
|
+
${content}
|
|
49
|
+
<div class="oasbox-resize-handle" aria-hidden="true" @mousedown="${startResize}"></div>
|
|
50
|
+
</div>
|
|
51
|
+
`;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
export function startRouter(root: HTMLElement): void {
|
|
@@ -46,15 +63,20 @@ export function startRouter(root: HTMLElement): void {
|
|
|
46
63
|
}
|
|
47
64
|
const page: Page = resolved;
|
|
48
65
|
document.title = page.title;
|
|
66
|
+
pageState.breadcrumb = page.breadcrumb;
|
|
67
|
+
if (page.defaultWidth !== undefined) {
|
|
68
|
+
applyDefaultWidth(page.defaultWidth);
|
|
69
|
+
}
|
|
49
70
|
root.replaceChildren();
|
|
50
71
|
|
|
51
72
|
let content: ArrowExpression;
|
|
52
73
|
if (page.sidebar && page.canvas) {
|
|
53
|
-
|
|
74
|
+
const canvasEl = page.viewCanvas ? ViewCanvas(page.canvas) : ComponentCanvas(page.canvas);
|
|
75
|
+
content = html`${page.sidebar}${Frame(page.view)}${canvasEl}`;
|
|
54
76
|
} else if (page.sidebar) {
|
|
55
|
-
content = html`${page.sidebar}${Frame(page.
|
|
77
|
+
content = html`${page.sidebar}${Frame(page.view)}`;
|
|
56
78
|
} else {
|
|
57
|
-
content = Frame(page.
|
|
79
|
+
content = Frame(page.view);
|
|
58
80
|
}
|
|
59
81
|
Shell(content)(root);
|
|
60
82
|
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { html } from "@arrow-js/core";
|
|
2
2
|
import type { ArrowExpression } from "@arrow-js/core";
|
|
3
3
|
import { Home } from "../sandbox/home";
|
|
4
|
+
import { EDITOR_DEFAULT_WIDTH, PANEL_DEFAULT_WIDTH } from "../sandbox/layout";
|
|
4
5
|
import { ClassesPage } from "../viewer/ClassesPage";
|
|
5
6
|
import { ComponentsIndex } from "../viewer/ComponentsIndex";
|
|
6
|
-
import { StoryPageCanvas, StoryPageDetails
|
|
7
|
+
import { StoryPageCanvas, StoryPageDetails } from "../viewer/StoryPage";
|
|
7
8
|
import { TokensPage } from "../viewer/TokensPage";
|
|
9
|
+
import { ViewsIndex } from "../viewer/ViewsIndex";
|
|
8
10
|
import { findStory } from "../viewer/discovery";
|
|
9
11
|
import { ViewerSidebar } from "../viewer/sidebar";
|
|
10
12
|
|
|
@@ -17,9 +19,16 @@ import { ViewerSidebar } from "../viewer/sidebar";
|
|
|
17
19
|
export interface Page {
|
|
18
20
|
status: number;
|
|
19
21
|
title: string;
|
|
22
|
+
/** Short breadcrumb shown in the toolbar (no app-name suffix). */
|
|
23
|
+
breadcrumb: string;
|
|
20
24
|
view: ArrowExpression;
|
|
21
25
|
sidebar?: ArrowExpression;
|
|
22
26
|
canvas?: ArrowExpression;
|
|
27
|
+
/** When true the canvas renders full-height (view story), not centered (component story). */
|
|
28
|
+
viewCanvas?: boolean;
|
|
29
|
+
/** Preferred initial pane width for this route (sandbox chrome). Applied by
|
|
30
|
+
* the client router until the tester drags the resize handle. */
|
|
31
|
+
defaultWidth?: number;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export interface Redirect {
|
|
@@ -32,8 +41,9 @@ function notFound(pathname: string): Page {
|
|
|
32
41
|
return {
|
|
33
42
|
status: 404,
|
|
34
43
|
title: `Not found · ${APP_NAME}`,
|
|
44
|
+
breadcrumb: "Not found",
|
|
35
45
|
view: html`
|
|
36
|
-
<div class="
|
|
46
|
+
<div class="oasbox-settings">
|
|
37
47
|
<div class="setting-item setting-item-heading">
|
|
38
48
|
<div class="setting-item-info">
|
|
39
49
|
<div class="setting-item-name">Not found</div>
|
|
@@ -51,7 +61,7 @@ export function routeToPage(url: string): Page | Redirect {
|
|
|
51
61
|
const { pathname, searchParams } = new URL(url, window.location.origin);
|
|
52
62
|
|
|
53
63
|
if (pathname === "/" || pathname === "") {
|
|
54
|
-
return { status: 200, title: APP_NAME, view: Home() };
|
|
64
|
+
return { status: 200, title: APP_NAME, breadcrumb: APP_NAME, view: Home() };
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
if (pathname === "/example") {
|
|
@@ -62,11 +72,22 @@ export function routeToPage(url: string): Page | Redirect {
|
|
|
62
72
|
return {
|
|
63
73
|
status: 200,
|
|
64
74
|
title: `Components · ${APP_NAME}`,
|
|
75
|
+
breadcrumb: "Components",
|
|
65
76
|
view: ComponentsIndex(),
|
|
66
77
|
sidebar: ViewerSidebar(pathname),
|
|
67
78
|
};
|
|
68
79
|
}
|
|
69
80
|
|
|
81
|
+
if (pathname === "/views" || pathname === "/views/") {
|
|
82
|
+
return {
|
|
83
|
+
status: 200,
|
|
84
|
+
title: `Views · ${APP_NAME}`,
|
|
85
|
+
breadcrumb: "Views",
|
|
86
|
+
view: ViewsIndex(),
|
|
87
|
+
sidebar: ViewerSidebar(pathname),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
const storyMatch = pathname.match(/^\/components\/([^/]+)$/);
|
|
71
92
|
if (storyMatch) {
|
|
72
93
|
const story = findStory(storyMatch[1]);
|
|
@@ -75,22 +96,15 @@ export function routeToPage(url: string): Page | Redirect {
|
|
|
75
96
|
}
|
|
76
97
|
const requested = searchParams.get("variant");
|
|
77
98
|
const variantName = requested ?? Object.keys(story.variants)[0];
|
|
78
|
-
// kind: "view" → full page in the frame
|
|
79
|
-
if (story.kind === "view") {
|
|
80
|
-
return {
|
|
81
|
-
status: story.variants[variantName] ? 200 : 404,
|
|
82
|
-
title: `${story.title} · ${APP_NAME}`,
|
|
83
|
-
view: StoryPageView(story, variantName),
|
|
84
|
-
sidebar: ViewerSidebar(`/components/${story.slug}`),
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
// kind: "component" → details in frame, canvas separate
|
|
88
99
|
return {
|
|
89
100
|
status: story.variants[variantName] ? 200 : 404,
|
|
90
101
|
title: `${story.title} · ${APP_NAME}`,
|
|
102
|
+
breadcrumb: story.title,
|
|
91
103
|
view: StoryPageDetails(story, variantName),
|
|
92
104
|
sidebar: ViewerSidebar(`/components/${story.slug}`),
|
|
93
105
|
canvas: StoryPageCanvas(story, variantName),
|
|
106
|
+
viewCanvas: story.kind === "view",
|
|
107
|
+
defaultWidth: story.surface === "editor" ? EDITOR_DEFAULT_WIDTH : PANEL_DEFAULT_WIDTH,
|
|
94
108
|
};
|
|
95
109
|
}
|
|
96
110
|
|
|
@@ -98,6 +112,7 @@ export function routeToPage(url: string): Page | Redirect {
|
|
|
98
112
|
return {
|
|
99
113
|
status: 200,
|
|
100
114
|
title: `Tokens · ${APP_NAME}`,
|
|
115
|
+
breadcrumb: "Tokens",
|
|
101
116
|
view: TokensPage(),
|
|
102
117
|
sidebar: ViewerSidebar(pathname),
|
|
103
118
|
};
|
|
@@ -107,6 +122,7 @@ export function routeToPage(url: string): Page | Redirect {
|
|
|
107
122
|
return {
|
|
108
123
|
status: 200,
|
|
109
124
|
title: `Classes · ${APP_NAME}`,
|
|
125
|
+
breadcrumb: "Classes",
|
|
110
126
|
view: ClassesPage(),
|
|
111
127
|
sidebar: ViewerSidebar(pathname),
|
|
112
128
|
};
|