create-obsidian-arrow 0.4.1 → 0.5.1
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/package.json +1 -1
- package/template/AGENTS.md +52 -17
- package/template/README.md +12 -14
- package/template/_gitignore +3 -0
- package/template/docs/prompts/agent-setup.md +2 -0
- package/template/docs/workflow.md +13 -7
- package/template/package.json +9 -2
- package/template/pnpm-lock.yaml +3 -0
- package/template/porting.config.example.json +6 -0
- package/template/scripts/check-orphaned-css.mjs +62 -0
- package/template/scripts/check-scope-classes.mjs +77 -0
- package/template/scripts/check-view-imports.mjs +133 -0
- package/template/scripts/component-hash.mjs +12 -1
- package/template/scripts/create-component.mjs +101 -0
- package/template/scripts/create-view.mjs +75 -0
- package/template/scripts/port-css.mjs +118 -0
- package/template/src/components/EmptyState/EmptyState.css +30 -0
- package/template/src/components/EmptyState/EmptyState.ts +35 -0
- package/template/src/components/LoadingState.ts +12 -0
- package/template/src/components/icons.ts +17 -0
- package/template/src/utilities.css +101 -1
- package/template/stories/components/EmptyState.stories.ts +25 -0
- package/template/stories/components/LoadingState.stories.ts +11 -0
- package/template/test/viewer-derive.test.mjs +6 -0
- package/template/test/viewer-stories.test.mjs +26 -0
- package/template/tools/router/client.ts +14 -3
- package/template/tools/router/routeToPage.ts +14 -2
- package/template/tools/sandbox/sandbox.css +13 -0
- package/template/tools/viewer/StoryPage.ts +35 -16
- package/template/tools/viewer/discovery.ts +6 -0
- package/template/tools/viewer/stories.ts +16 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Usage:
|
|
4
|
+
* pnpm create:view ChatView → src/views/ChatView/ + stories/views/ChatView.stories.ts
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const viewName = process.argv[2];
|
|
12
|
+
|
|
13
|
+
if (!viewName) {
|
|
14
|
+
console.error("Usage: pnpm create:view <ViewName>");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const viewDir = path.join(root, "src", "views", viewName);
|
|
19
|
+
const storyDir = path.join(root, "stories", "views");
|
|
20
|
+
|
|
21
|
+
if (fs.existsSync(viewDir)) {
|
|
22
|
+
console.error(`Already exists: src/views/${viewName}/`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fs.mkdirSync(viewDir, { recursive: true });
|
|
27
|
+
fs.mkdirSync(storyDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
const tsContent = `import "./${viewName}.css";
|
|
30
|
+
import { component, html } from "@arrow-js/core";
|
|
31
|
+
import type { ArrowTemplate } from "@arrow-js/core";
|
|
32
|
+
|
|
33
|
+
export const ${viewName} = component((): ArrowTemplate => {
|
|
34
|
+
return html\`
|
|
35
|
+
<div class="oas-settings">
|
|
36
|
+
<div class="setting-item setting-item-heading">
|
|
37
|
+
<div class="setting-item-info">
|
|
38
|
+
<div class="setting-item-name">${viewName}</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
\`;
|
|
43
|
+
});
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const cssContent = `/* ${viewName} layout and chrome */\n`;
|
|
47
|
+
|
|
48
|
+
const stateContent = `import { reactive } from "@arrow-js/core";
|
|
49
|
+
|
|
50
|
+
export const state = reactive({
|
|
51
|
+
// TODO: add view state
|
|
52
|
+
});
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const storyContent = `import { defineStories } from "../../tools/viewer/stories";
|
|
56
|
+
import { ${viewName} } from "../../src/views/${viewName}/${viewName}";
|
|
57
|
+
|
|
58
|
+
export default defineStories({
|
|
59
|
+
kind: "view",
|
|
60
|
+
description: "TODO: describe ${viewName}.",
|
|
61
|
+
status: "draft",
|
|
62
|
+
variants: {
|
|
63
|
+
default: () => ${viewName}(),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(path.join(viewDir, `${viewName}.ts`), tsContent);
|
|
69
|
+
fs.writeFileSync(path.join(viewDir, `${viewName}.css`), cssContent);
|
|
70
|
+
fs.writeFileSync(path.join(viewDir, "state.ts"), stateContent);
|
|
71
|
+
fs.writeFileSync(path.join(storyDir, `${viewName}.stories.ts`), storyContent);
|
|
72
|
+
|
|
73
|
+
console.log(`Created src/views/${viewName}/`);
|
|
74
|
+
console.log(` ${viewName}.ts, ${viewName}.css, state.ts`);
|
|
75
|
+
console.log(`Created stories/views/${viewName}.stories.ts`);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostCSS prefix pipeline — port time only, never modifies source files.
|
|
4
|
+
*
|
|
5
|
+
* Usage: pnpm port:css
|
|
6
|
+
*
|
|
7
|
+
* Reads porting.config.json, walks CSS files matching `include` globs,
|
|
8
|
+
* prefixes all class selectors with `cssPrefix`, writes prefixed output
|
|
9
|
+
* to `outDir/`. Also generates outDir/index.css that @imports all outputs.
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import postcss from "postcss";
|
|
15
|
+
|
|
16
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const configPath = path.join(root, "porting.config.json");
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(configPath)) {
|
|
20
|
+
console.error("porting.config.json not found. Create it at the project root.");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
25
|
+
const {
|
|
26
|
+
cssPrefix = "my-plugin-",
|
|
27
|
+
outDir = "port-output/css",
|
|
28
|
+
include = [],
|
|
29
|
+
viewSubScope = false,
|
|
30
|
+
} = config;
|
|
31
|
+
|
|
32
|
+
// Kebab-case a PascalCase view name for the ancestor selector.
|
|
33
|
+
function toKebab(name) {
|
|
34
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Collect CSS files matching include patterns (simple glob expansion)
|
|
38
|
+
function matchesGlob(file, pattern) {
|
|
39
|
+
// Convert glob to regex: ** → .*, * → [^/]*
|
|
40
|
+
const re = new RegExp(
|
|
41
|
+
`^${pattern.replace(/\*\*/g, "@@").replace(/\*/g, "[^/]*").replace(/@@/g, ".*")}$`
|
|
42
|
+
);
|
|
43
|
+
return re.test(file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function walk(dir, results = []) {
|
|
47
|
+
if (!fs.existsSync(dir)) return results;
|
|
48
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
49
|
+
const full = path.join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) walk(full, results);
|
|
51
|
+
else if (entry.name.endsWith(".css")) results.push(full);
|
|
52
|
+
}
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const allCss = walk(path.join(root, "src"));
|
|
57
|
+
const cssFiles = allCss.filter((f) => {
|
|
58
|
+
const rel = path.relative(root, f).replace(/\\/g, "/");
|
|
59
|
+
return include.some((pattern) => matchesGlob(rel, pattern));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (cssFiles.length === 0) {
|
|
63
|
+
console.log("No CSS files matched include patterns.");
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// PostCSS plugin: prefix all class selectors; optionally prepend an ancestor.
|
|
68
|
+
// When ancestorClass is provided (viewSubScope for view-folder CSS), every rule
|
|
69
|
+
// selector gains the ancestor: ".composer" → ".vault-mind-chat-view .vault-mind-composer"
|
|
70
|
+
const prefixPlugin = (prefix, ancestorClass) => ({
|
|
71
|
+
postcssPlugin: "postcss-prefix-classes",
|
|
72
|
+
Rule(rule) {
|
|
73
|
+
rule.selector = rule.selector.replace(/\.([a-zA-Z][\w-]*)/g, `.${prefix}$1`);
|
|
74
|
+
if (ancestorClass) {
|
|
75
|
+
rule.selector = rule.selector
|
|
76
|
+
.split(",")
|
|
77
|
+
.map((s) => `${ancestorClass} ${s.trim()}`)
|
|
78
|
+
.join(", ");
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
prefixPlugin.postcss = true;
|
|
83
|
+
|
|
84
|
+
const outDirAbs = path.join(root, outDir);
|
|
85
|
+
fs.mkdirSync(outDirAbs, { recursive: true });
|
|
86
|
+
|
|
87
|
+
const outputFiles = [];
|
|
88
|
+
|
|
89
|
+
for (const cssFile of cssFiles) {
|
|
90
|
+
const rel = path.relative(path.join(root, "src"), cssFile).replace(/\\/g, "/");
|
|
91
|
+
const outFile = path.join(outDirAbs, rel);
|
|
92
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
93
|
+
|
|
94
|
+
// Determine view ancestor class when viewSubScope is enabled.
|
|
95
|
+
// src/views/ChatView/X.css → ancestor ".vault-mind-chat-view"
|
|
96
|
+
let ancestorClass = null;
|
|
97
|
+
if (viewSubScope) {
|
|
98
|
+
const viewMatch = rel.match(/^views\/([^/]+)\//);
|
|
99
|
+
if (viewMatch) {
|
|
100
|
+
ancestorClass = `.${cssPrefix}${toKebab(viewMatch[1])}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const source = fs.readFileSync(cssFile, "utf8");
|
|
105
|
+
const result = await postcss([prefixPlugin(cssPrefix, ancestorClass)]).process(source, {
|
|
106
|
+
from: cssFile,
|
|
107
|
+
to: outFile,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
fs.writeFileSync(outFile, result.css);
|
|
111
|
+
outputFiles.push(path.relative(outDirAbs, outFile).replace(/\\/g, "/"));
|
|
112
|
+
console.log(`Prefixed: ${rel} → ${path.relative(root, outFile)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Generate index.css
|
|
116
|
+
const indexContent = `${outputFiles.map((f) => `@import "./${f}";`).join("\n")}\n`;
|
|
117
|
+
fs.writeFileSync(path.join(outDirAbs, "index.css"), indexContent);
|
|
118
|
+
console.log(`\nGenerated: ${outDir}/index.css (${outputFiles.length} files)`);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
.empty-state {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: var(--size-4-2);
|
|
6
|
+
padding: var(--size-4-4);
|
|
7
|
+
text-align: center;
|
|
8
|
+
color: var(--text-muted);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.empty-state-icon {
|
|
12
|
+
font-size: var(--font-ui-larger, 2rem);
|
|
13
|
+
line-height: 1;
|
|
14
|
+
color: var(--text-faint);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.empty-state-title {
|
|
18
|
+
font-size: var(--font-ui-medium);
|
|
19
|
+
font-weight: var(--font-semibold);
|
|
20
|
+
color: var(--text-normal);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.empty-state-description {
|
|
24
|
+
font-size: var(--font-ui-small);
|
|
25
|
+
max-width: 280px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.empty-state-action {
|
|
29
|
+
margin-top: var(--size-4-2);
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import "./EmptyState.css";
|
|
2
|
+
import { html } from "@arrow-js/core";
|
|
3
|
+
import type { ArrowExpression } from "@arrow-js/core";
|
|
4
|
+
import { icon } from "../icons";
|
|
5
|
+
|
|
6
|
+
export interface EmptyStateOptions {
|
|
7
|
+
icon?: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
action?: { label: string; onClick: () => void };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function EmptyState(options: EmptyStateOptions): ArrowExpression {
|
|
14
|
+
return html`
|
|
15
|
+
<div class="empty-state">
|
|
16
|
+
${options.icon ? html`<div class="empty-state-icon">${icon(options.icon)}</div>` : ""}
|
|
17
|
+
<div class="empty-state-title">${options.title}</div>
|
|
18
|
+
${
|
|
19
|
+
options.description
|
|
20
|
+
? html`<div class="empty-state-description">${options.description}</div>`
|
|
21
|
+
: ""
|
|
22
|
+
}
|
|
23
|
+
${
|
|
24
|
+
options.action
|
|
25
|
+
? html`<button
|
|
26
|
+
class="mod-cta empty-state-action"
|
|
27
|
+
@click="${options.action.onClick}"
|
|
28
|
+
>
|
|
29
|
+
${options.action.label}
|
|
30
|
+
</button>`
|
|
31
|
+
: ""
|
|
32
|
+
}
|
|
33
|
+
</div>
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { html } from "@arrow-js/core";
|
|
2
|
+
import type { ArrowExpression } from "@arrow-js/core";
|
|
3
|
+
import { icon } from "./icons";
|
|
4
|
+
|
|
5
|
+
export function LoadingState(message?: string): ArrowExpression {
|
|
6
|
+
return html`
|
|
7
|
+
<div class="oas-flex oas-flex-col oas-items-center oas-gap-2 oas-p-4 oas-text-muted">
|
|
8
|
+
<span>${icon("loader")}</span>
|
|
9
|
+
${message ? html`<span class="oas-text-sm">${message}</span>` : ""}
|
|
10
|
+
</div>
|
|
11
|
+
`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const ICON_MAP: Record<string, string> = {
|
|
2
|
+
check: "✓",
|
|
3
|
+
x: "✕",
|
|
4
|
+
"chevron-right": "›",
|
|
5
|
+
"chevron-down": "⌄",
|
|
6
|
+
loader: "◌",
|
|
7
|
+
search: "⌕",
|
|
8
|
+
file: "◻",
|
|
9
|
+
folder: "◱",
|
|
10
|
+
info: "ℹ",
|
|
11
|
+
warning: "⚠",
|
|
12
|
+
error: "✕",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function icon(name: string): string {
|
|
16
|
+
return ICON_MAP[name] ?? "•";
|
|
17
|
+
}
|
|
@@ -52,7 +52,56 @@
|
|
|
52
52
|
flex-shrink: 0;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/*
|
|
55
|
+
/*
|
|
56
|
+
* Spacing — two Obsidian token families:
|
|
57
|
+
*
|
|
58
|
+
* 4-px scale oas-gap-N / oas-p-N / oas-px-N / oas-py-N / oas-mt-N / oas-mb-N
|
|
59
|
+
* N=1 → --size-4-1 (4px) N=2 → --size-4-2 (8px) N=3 → --size-4-3 (12px)
|
|
60
|
+
* N=4 → --size-4-4 (16px) N=5 → --size-4-5 (20px) N=6 → --size-4-6 (24px)
|
|
61
|
+
*
|
|
62
|
+
* 2-px sub-scale oas-gap-2-1 / oas-p-2-1 … (mirrors Obsidian token name)
|
|
63
|
+
* 2-1 → --size-2-1 (2px) 2-3 → --size-2-3 (6px)
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/* 2-px sub-scale ── */
|
|
67
|
+
.oas-gap-2-1 {
|
|
68
|
+
gap: var(--size-2-1);
|
|
69
|
+
}
|
|
70
|
+
.oas-p-2-1 {
|
|
71
|
+
padding: var(--size-2-1);
|
|
72
|
+
}
|
|
73
|
+
.oas-px-2-1 {
|
|
74
|
+
padding-inline: var(--size-2-1);
|
|
75
|
+
}
|
|
76
|
+
.oas-py-2-1 {
|
|
77
|
+
padding-block: var(--size-2-1);
|
|
78
|
+
}
|
|
79
|
+
.oas-mt-2-1 {
|
|
80
|
+
margin-top: var(--size-2-1);
|
|
81
|
+
}
|
|
82
|
+
.oas-mb-2-1 {
|
|
83
|
+
margin-bottom: var(--size-2-1);
|
|
84
|
+
}
|
|
85
|
+
.oas-gap-2-3 {
|
|
86
|
+
gap: var(--size-2-3);
|
|
87
|
+
}
|
|
88
|
+
.oas-p-2-3 {
|
|
89
|
+
padding: var(--size-2-3);
|
|
90
|
+
}
|
|
91
|
+
.oas-px-2-3 {
|
|
92
|
+
padding-inline: var(--size-2-3);
|
|
93
|
+
}
|
|
94
|
+
.oas-py-2-3 {
|
|
95
|
+
padding-block: var(--size-2-3);
|
|
96
|
+
}
|
|
97
|
+
.oas-mt-2-3 {
|
|
98
|
+
margin-top: var(--size-2-3);
|
|
99
|
+
}
|
|
100
|
+
.oas-mb-2-3 {
|
|
101
|
+
margin-bottom: var(--size-2-3);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* 4-px scale ── */
|
|
56
105
|
.oas-gap-1 {
|
|
57
106
|
gap: var(--size-4-1);
|
|
58
107
|
}
|
|
@@ -65,6 +114,12 @@
|
|
|
65
114
|
.oas-gap-4 {
|
|
66
115
|
gap: var(--size-4-4);
|
|
67
116
|
}
|
|
117
|
+
.oas-gap-5 {
|
|
118
|
+
gap: var(--size-4-5);
|
|
119
|
+
}
|
|
120
|
+
.oas-gap-6 {
|
|
121
|
+
gap: var(--size-4-6);
|
|
122
|
+
}
|
|
68
123
|
.oas-p-1 {
|
|
69
124
|
padding: var(--size-4-1);
|
|
70
125
|
}
|
|
@@ -77,12 +132,27 @@
|
|
|
77
132
|
.oas-p-4 {
|
|
78
133
|
padding: var(--size-4-4);
|
|
79
134
|
}
|
|
135
|
+
.oas-p-5 {
|
|
136
|
+
padding: var(--size-4-5);
|
|
137
|
+
}
|
|
138
|
+
.oas-p-6 {
|
|
139
|
+
padding: var(--size-4-6);
|
|
140
|
+
}
|
|
80
141
|
.oas-px-2 {
|
|
81
142
|
padding-inline: var(--size-4-2);
|
|
82
143
|
}
|
|
83
144
|
.oas-px-3 {
|
|
84
145
|
padding-inline: var(--size-4-3);
|
|
85
146
|
}
|
|
147
|
+
.oas-px-4 {
|
|
148
|
+
padding-inline: var(--size-4-4);
|
|
149
|
+
}
|
|
150
|
+
.oas-px-5 {
|
|
151
|
+
padding-inline: var(--size-4-5);
|
|
152
|
+
}
|
|
153
|
+
.oas-px-6 {
|
|
154
|
+
padding-inline: var(--size-4-6);
|
|
155
|
+
}
|
|
86
156
|
.oas-py-1 {
|
|
87
157
|
padding-block: var(--size-4-1);
|
|
88
158
|
}
|
|
@@ -92,6 +162,15 @@
|
|
|
92
162
|
.oas-py-3 {
|
|
93
163
|
padding-block: var(--size-4-3);
|
|
94
164
|
}
|
|
165
|
+
.oas-py-4 {
|
|
166
|
+
padding-block: var(--size-4-4);
|
|
167
|
+
}
|
|
168
|
+
.oas-py-5 {
|
|
169
|
+
padding-block: var(--size-4-5);
|
|
170
|
+
}
|
|
171
|
+
.oas-py-6 {
|
|
172
|
+
padding-block: var(--size-4-6);
|
|
173
|
+
}
|
|
95
174
|
.oas-mt-1 {
|
|
96
175
|
margin-top: var(--size-4-1);
|
|
97
176
|
}
|
|
@@ -101,6 +180,15 @@
|
|
|
101
180
|
.oas-mt-3 {
|
|
102
181
|
margin-top: var(--size-4-3);
|
|
103
182
|
}
|
|
183
|
+
.oas-mt-4 {
|
|
184
|
+
margin-top: var(--size-4-4);
|
|
185
|
+
}
|
|
186
|
+
.oas-mt-5 {
|
|
187
|
+
margin-top: var(--size-4-5);
|
|
188
|
+
}
|
|
189
|
+
.oas-mt-6 {
|
|
190
|
+
margin-top: var(--size-4-6);
|
|
191
|
+
}
|
|
104
192
|
.oas-mb-1 {
|
|
105
193
|
margin-bottom: var(--size-4-1);
|
|
106
194
|
}
|
|
@@ -110,6 +198,15 @@
|
|
|
110
198
|
.oas-mb-3 {
|
|
111
199
|
margin-bottom: var(--size-4-3);
|
|
112
200
|
}
|
|
201
|
+
.oas-mb-4 {
|
|
202
|
+
margin-bottom: var(--size-4-4);
|
|
203
|
+
}
|
|
204
|
+
.oas-mb-5 {
|
|
205
|
+
margin-bottom: var(--size-4-5);
|
|
206
|
+
}
|
|
207
|
+
.oas-mb-6 {
|
|
208
|
+
margin-bottom: var(--size-4-6);
|
|
209
|
+
}
|
|
113
210
|
.oas-ml-auto {
|
|
114
211
|
margin-left: auto;
|
|
115
212
|
}
|
|
@@ -195,6 +292,9 @@
|
|
|
195
292
|
.oas-rounded-m {
|
|
196
293
|
border-radius: var(--radius-m);
|
|
197
294
|
}
|
|
295
|
+
.oas-rounded-l {
|
|
296
|
+
border-radius: var(--radius-l);
|
|
297
|
+
}
|
|
198
298
|
|
|
199
299
|
/* ── Interaction ─────────────────────────────────────────────── */
|
|
200
300
|
.oas-cursor-pointer {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { EmptyState } from "../../src/components/EmptyState/EmptyState";
|
|
2
|
+
import { defineStories } from "../../tools/viewer/stories";
|
|
3
|
+
|
|
4
|
+
export default defineStories({
|
|
5
|
+
description: "Reusable empty state for any view — icon, title, description, optional action.",
|
|
6
|
+
status: "live",
|
|
7
|
+
variants: {
|
|
8
|
+
default: () => EmptyState({ title: "Nothing here yet" }),
|
|
9
|
+
"with description": () => {
|
|
10
|
+
return EmptyState({
|
|
11
|
+
icon: "file",
|
|
12
|
+
title: "No files found",
|
|
13
|
+
description: "Try a different search or create a new file.",
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
"with action": () => {
|
|
17
|
+
return EmptyState({
|
|
18
|
+
icon: "search",
|
|
19
|
+
title: "No results",
|
|
20
|
+
description: "Your search returned no matches.",
|
|
21
|
+
action: { label: "Clear search", onClick: () => {} },
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { LoadingState } from "../../src/components/LoadingState";
|
|
2
|
+
import { defineStories } from "../../tools/viewer/stories";
|
|
3
|
+
|
|
4
|
+
export default defineStories({
|
|
5
|
+
description: "Loading indicator for async view content.",
|
|
6
|
+
status: "live",
|
|
7
|
+
variants: {
|
|
8
|
+
default: () => LoadingState(),
|
|
9
|
+
"with message": () => LoadingState("Loading sessions…"),
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -63,3 +63,9 @@ test("buildStoryTree guards cycles: mutual refs fall back to flat roots", () =>
|
|
|
63
63
|
assert.equal(roots[0].children[0].slug, "b");
|
|
64
64
|
assert.equal(roots[0].children[0].children.length, 0);
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
test("storyMetaFromGlobKey: stories/views/ path auto-kind is view", () => {
|
|
68
|
+
const meta = storyMetaFromGlobKey("../../stories/views/ChatView.stories.ts");
|
|
69
|
+
// auto-kind logic lives in discovery.ts, not derive.ts — just confirm storiesPath prefix
|
|
70
|
+
assert.ok(meta.storiesPath.startsWith("stories/views/"));
|
|
71
|
+
});
|
|
@@ -42,3 +42,29 @@ test("normalizeVariants wraps bare functions and passes objects through", () =>
|
|
|
42
42
|
assert.equal(out.full.render, fn);
|
|
43
43
|
assert.equal(out.full.notes, "n");
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test("validateStoryDef accepts kind: view and kind: component", () => {
|
|
47
|
+
const base = { variants: { default: () => {} } };
|
|
48
|
+
assert.deepEqual(validateStoryDef({ ...base, kind: "view" }), { ok: true });
|
|
49
|
+
assert.deepEqual(validateStoryDef({ ...base, kind: "component" }), { ok: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("validateStoryDef rejects invalid kind", () => {
|
|
53
|
+
const r = validateStoryDef({ variants: { default: () => {} }, kind: "panel" });
|
|
54
|
+
assert.equal(r.ok, false);
|
|
55
|
+
assert.match(r.reason, /kind/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("validateStoryDef accepts decorator function", () => {
|
|
59
|
+
const r = validateStoryDef({
|
|
60
|
+
variants: { default: () => {} },
|
|
61
|
+
decorator: (c) => c,
|
|
62
|
+
});
|
|
63
|
+
assert.deepEqual(r, { ok: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("validateStoryDef rejects non-function decorator", () => {
|
|
67
|
+
const r = validateStoryDef({ variants: { default: () => {} }, decorator: "bad" });
|
|
68
|
+
assert.equal(r.ok, false);
|
|
69
|
+
assert.match(r.reason, /decorator/);
|
|
70
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html } from "@arrow-js/core";
|
|
2
|
+
import type { ArrowExpression } from "@arrow-js/core";
|
|
2
3
|
import { Frame } from "../sandbox/frame";
|
|
3
4
|
import { Shell } from "../sandbox/shell";
|
|
4
5
|
import type { Page } from "./routeToPage";
|
|
@@ -29,6 +30,10 @@ function getNavigation(): NavigationLike | undefined {
|
|
|
29
30
|
return (window as unknown as { navigation?: NavigationLike }).navigation;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function ComponentCanvas(content: ArrowExpression): ArrowExpression {
|
|
34
|
+
return html`<div class="oas-component-canvas">${content}</div>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
export function startRouter(root: HTMLElement): void {
|
|
33
38
|
const render = (url: string): void => {
|
|
34
39
|
let resolved = routeToPage(url);
|
|
@@ -42,9 +47,15 @@ export function startRouter(root: HTMLElement): void {
|
|
|
42
47
|
const page: Page = resolved;
|
|
43
48
|
document.title = page.title;
|
|
44
49
|
root.replaceChildren();
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
|
|
51
|
+
let content: ArrowExpression;
|
|
52
|
+
if (page.sidebar && page.canvas) {
|
|
53
|
+
content = html`${page.sidebar}${Frame(page.title, page.view)}${ComponentCanvas(page.canvas)}`;
|
|
54
|
+
} else if (page.sidebar) {
|
|
55
|
+
content = html`${page.sidebar}${Frame(page.title, page.view)}`;
|
|
56
|
+
} else {
|
|
57
|
+
content = Frame(page.title, page.view);
|
|
58
|
+
}
|
|
48
59
|
Shell(content)(root);
|
|
49
60
|
};
|
|
50
61
|
|
|
@@ -3,7 +3,7 @@ import type { ArrowExpression } from "@arrow-js/core";
|
|
|
3
3
|
import { Home } from "../sandbox/home";
|
|
4
4
|
import { ClassesPage } from "../viewer/ClassesPage";
|
|
5
5
|
import { ComponentsIndex } from "../viewer/ComponentsIndex";
|
|
6
|
-
import {
|
|
6
|
+
import { StoryPageCanvas, StoryPageDetails, StoryPageView } from "../viewer/StoryPage";
|
|
7
7
|
import { TokensPage } from "../viewer/TokensPage";
|
|
8
8
|
import { findStory } from "../viewer/discovery";
|
|
9
9
|
import { ViewerSidebar } from "../viewer/sidebar";
|
|
@@ -19,6 +19,7 @@ export interface Page {
|
|
|
19
19
|
title: string;
|
|
20
20
|
view: ArrowExpression;
|
|
21
21
|
sidebar?: ArrowExpression;
|
|
22
|
+
canvas?: ArrowExpression;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface Redirect {
|
|
@@ -74,11 +75,22 @@ export function routeToPage(url: string): Page | Redirect {
|
|
|
74
75
|
}
|
|
75
76
|
const requested = searchParams.get("variant");
|
|
76
77
|
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
|
|
77
88
|
return {
|
|
78
89
|
status: story.variants[variantName] ? 200 : 404,
|
|
79
90
|
title: `${story.title} · ${APP_NAME}`,
|
|
80
|
-
view:
|
|
91
|
+
view: StoryPageDetails(story, variantName),
|
|
81
92
|
sidebar: ViewerSidebar(`/components/${story.slug}`),
|
|
93
|
+
canvas: StoryPageCanvas(story, variantName),
|
|
82
94
|
};
|
|
83
95
|
}
|
|
84
96
|
|
|
@@ -130,6 +130,19 @@ body {
|
|
|
130
130
|
opacity: 0.5;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/* Component canvas — fills remaining stage width for kind: "component" stories */
|
|
134
|
+
.oas-component-canvas {
|
|
135
|
+
flex: 1;
|
|
136
|
+
min-width: 0;
|
|
137
|
+
min-height: 0;
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
justify-content: center;
|
|
141
|
+
padding: var(--size-4-4);
|
|
142
|
+
overflow: auto;
|
|
143
|
+
background: var(--background-secondary);
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
/* Collapsible card — Getting Started accordion and other expandable sections. */
|
|
134
147
|
.oas-card {
|
|
135
148
|
border-bottom: 1px solid var(--background-modifier-border);
|