create-obsidian-arrow 0.5.1 → 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.
Files changed (61) hide show
  1. package/README.md +7 -7
  2. package/cli/create.mjs +65 -0
  3. package/cli/detect-pm.mjs +20 -0
  4. package/cli/lib.mjs +117 -0
  5. package/cli/refresh.mjs +65 -0
  6. package/index.mjs +47 -204
  7. package/package.json +11 -2
  8. package/template/.husky/pre-commit +3 -2
  9. package/template/AGENTS.md +57 -12
  10. package/template/README.md +66 -31
  11. package/template/_gitignore +4 -1
  12. package/template/biome.json +7 -1
  13. package/template/docs/prompts/agent-setup.md +22 -20
  14. package/template/docs/prompts/update-existing.md +3 -3
  15. package/template/docs/workflow.md +11 -7
  16. package/template/package.json +15 -14
  17. package/template/src/components/DiffViewer/DiffViewer.css +41 -0
  18. package/template/src/components/DiffViewer/DiffViewer.ts +55 -0
  19. package/template/src/components/EmptyState/EmptyState.css +5 -5
  20. package/template/src/components/EmptyState/EmptyState.ts +5 -9
  21. package/template/src/utilities.css +158 -0
  22. package/template/src/views/DiffViewer/DiffViewerView.css +42 -0
  23. package/template/src/views/DiffViewer/DiffViewerView.ts +53 -0
  24. package/template/src/views/ExampleView/ExampleView.ts +92 -0
  25. package/template/stories/components/ComponentShell.stories.ts +28 -0
  26. package/template/stories/components/EmptyState.stories.ts +1 -0
  27. package/template/stories/components/LoadingState.stories.ts +1 -0
  28. package/template/stories/components/Toggle.stories.ts +50 -0
  29. package/template/stories/views/DiffViewer/DiffViewer.stories.ts +94 -0
  30. package/template/stories/views/EditorView.stories.ts +55 -0
  31. package/template/stories/views/ExampleView/ExampleView.stories.ts +15 -0
  32. package/template/stories/views/PanelView.stories.ts +61 -0
  33. package/template/stories/views/SettingsPanel/SettingsPanel.stories.ts +14 -0
  34. package/template/test/css-structure.test.mjs +112 -0
  35. package/template/test/template-footguns.test.mjs +85 -6
  36. package/template/test/viewer-stories.test.mjs +12 -0
  37. package/template/tools/router/client.ts +26 -4
  38. package/template/tools/router/routeToPage.ts +29 -13
  39. package/template/tools/sandbox/frame.ts +7 -27
  40. package/template/tools/sandbox/home.ts +6 -11
  41. package/template/tools/sandbox/layout.ts +24 -2
  42. package/template/tools/sandbox/sandbox.css +188 -226
  43. package/template/tools/sandbox/shell.ts +2 -2
  44. package/template/tools/sandbox/toolbar.ts +20 -9
  45. package/template/tools/viewer/ClassesPage.ts +7 -7
  46. package/template/tools/viewer/ComponentsIndex.ts +3 -3
  47. package/template/tools/viewer/StoryPage.ts +53 -40
  48. package/template/tools/viewer/TokensPage.ts +10 -10
  49. package/template/tools/viewer/ViewsIndex.ts +66 -0
  50. package/template/tools/viewer/discovery.ts +2 -0
  51. package/template/tools/viewer/obsidian-classes.ts +1 -1
  52. package/template/tools/viewer/sidebar.ts +27 -38
  53. package/template/tools/viewer/stories.ts +16 -2
  54. package/template/.github/workflows/ci.yml +0 -36
  55. package/template/pnpm-lock.yaml +0 -1608
  56. package/template/scripts/create-component.mjs +0 -101
  57. package/template/scripts/create-view.mjs +0 -75
  58. package/template/src/components/DiffViewer.ts +0 -42
  59. package/template/stories/DiffViewer.stories.ts +0 -75
  60. package/template/stories/SettingsPanel.stories.ts +0 -11
  61. package/template/stories/Toggle.stories.ts +0 -28
@@ -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 srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "src");
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 = tsFiles(srcDir).filter((file) =>
30
- fs.readFileSync(file, "utf8").includes("<!--")
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(srcDir, f)),
39
+ offenders.map((f) => path.relative(root, f)),
34
40
  [],
35
- "HTML comments break Arrow templates — move them to JS // comments"
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`<div class="oas-component-canvas">${content}</div>`;
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
- content = html`${page.sidebar}${Frame(page.title, page.view)}${ComponentCanvas(page.canvas)}`;
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.title, page.view)}`;
77
+ content = html`${page.sidebar}${Frame(page.view)}`;
56
78
  } else {
57
- content = Frame(page.title, page.view);
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, StoryPageView } from "../viewer/StoryPage";
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="oas-settings">
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
  };
@@ -1,35 +1,15 @@
1
1
  import { html } from "@arrow-js/core";
2
2
  import type { ArrowExpression, ArrowTemplate } from "@arrow-js/core";
3
- import { layoutState, startResize } from "./layout";
4
- import { themeState, toggleTheme } from "./theme";
5
3
 
6
4
  /**
7
- * Wraps a route view in an Obsidian workspace-leaf shell so the sandbox looks
8
- * like a real side-panel view. Uses Obsidian's own layout classes
9
- * (.workspace-leaf, .view-header, .view-content) so the chrome is styled by
10
- * app.css. Width is driven by reactive layout state (toolbar slider / presets /
11
- * the edge drag handle); height fills the stage.
5
+ * Story details frame fixed-width left panel showing story metadata
6
+ * (REFERENCES, VARIANTS, etc.). This is sandbox chrome, not an Obsidian leaf.
12
7
  *
13
- * Sandbox chrome only the route view mounted inside is what ports to a plugin.
8
+ * No view-header here: the breadcrumb lives in the top toolbar, and the right
9
+ * canvas shows whatever view-header the story component defines.
14
10
  */
15
- export const Frame = (title: string, content: ArrowExpression): ArrowTemplate => html`
16
- <div class="workspace-leaf oas-frame" style="${() => `width:${layoutState.width}px`}">
17
- <div class="workspace-leaf-content" data-type="arrow-sandbox">
18
- <div class="view-header">
19
- <div class="oas-view-header-left">
20
- <a class="clickable-icon oas-home" href="/" aria-label="Examples">⌂</a>
21
- <div class="view-header-title">${title}</div>
22
- </div>
23
- <div class="view-actions">
24
- <button
25
- class="clickable-icon oas-theme-toggle"
26
- aria-label="Toggle theme"
27
- @click="${toggleTheme}"
28
- >${() => (themeState.theme === "theme-dark" ? "☾" : "☀")}</button>
29
- </div>
30
- </div>
31
- <div class="view-content oas-view-content">${content}</div>
32
- </div>
33
- <div class="oas-resize-handle" aria-hidden="true" @mousedown="${startResize}"></div>
11
+ export const Frame = (content: ArrowExpression): ArrowTemplate => html`
12
+ <div class="oasbox-frame">
13
+ <div class="oasbox-view-content">${content}</div>
34
14
  </div>
35
15
  `;
@@ -30,6 +30,7 @@ const GETTING_STARTED = [
30
30
 
31
31
  const VIEWS = [
32
32
  { label: "Components", path: "/components", note: "Component story viewer" },
33
+ { label: "Views", path: "/views", note: "Full-pane view stories" },
33
34
  { label: "Tokens", path: "/reference", note: "CSS custom property reference" },
34
35
  { label: "Classes", path: "/reference/classes", note: "Obsidian class catalog" },
35
36
  ];
@@ -38,7 +39,7 @@ export const Home = component((): ArrowTemplate => {
38
39
  setTimeout(recheck, 250);
39
40
 
40
41
  return html`
41
- <div class="oas-settings">
42
+ <div class="oasbox-settings">
42
43
  <div class="setting-item setting-item-heading">
43
44
  <div class="setting-item-info">
44
45
  <div class="setting-item-name">Obsidian Arrow Sandbox</div>
@@ -63,7 +64,7 @@ export const Home = component((): ArrowTemplate => {
63
64
  style="${() =>
64
65
  `color: ${stylingLoaded() ? "var(--text-success)" : "var(--text-error)"}; font-weight: var(--font-semibold);`}"
65
66
  >${() => (stylingLoaded() ? "READY" : "MISSING")}</span>
66
- <button class="oas-recheck" @click="${recheck}">Re-check</button>
67
+ <button class="oasbox-recheck" @click="${recheck}">Re-check</button>
67
68
  </div>
68
69
  </div>
69
70
 
@@ -88,7 +89,7 @@ export const Home = component((): ArrowTemplate => {
88
89
  <span class="oas-card-chevron">›</span>
89
90
  </div>
90
91
  <div class="oas-card-body">
91
- <div class="oas-settings">
92
+ <div class="oasbox-settings">
92
93
  ${GETTING_STARTED.map((step) =>
93
94
  html`
94
95
  <div class="setting-item">
@@ -108,13 +109,7 @@ export const Home = component((): ArrowTemplate => {
108
109
  </div>
109
110
  </div>
110
111
 
111
- <div class="oas-settings">
112
- <div class="setting-item setting-item-heading">
113
- <div class="setting-item-info">
114
- <div class="setting-item-name">Views</div>
115
- <div class="setting-item-description">Main pages in this sandbox.</div>
116
- </div>
117
- </div>
112
+ <div class="oasbox-settings">
118
113
  ${VIEWS.map((view) =>
119
114
  html`
120
115
  <div class="setting-item">
@@ -125,7 +120,7 @@ export const Home = component((): ArrowTemplate => {
125
120
  <div class="setting-item-description">${view.note}</div>
126
121
  </div>
127
122
  <div class="setting-item-control">
128
- <a class="mod-cta oas-open-link" href="${view.path}">Open</a>
123
+ <a class="mod-cta oasbox-open-link" href="${view.path}">Open</a>
129
124
  </div>
130
125
  </div>
131
126
  `.key(view.label)
@@ -6,9 +6,30 @@ import { reactive } from "@arrow-js/core";
6
6
  * chrome — not ported into the plugin.
7
7
  */
8
8
  export const MIN_WIDTH = 240;
9
- export const WIDTH_PRESETS = [280, 360, 480, 640];
9
+ export const WIDTH_PRESETS = [280, 360, 480, 640, 800];
10
10
 
11
- export const layoutState = reactive<{ width: number }>({ width: 420 });
11
+ /** Sensible starting pane width per view chrome. Editor (readable-line-width)
12
+ * views need a pane wider than --file-line-width (700px) for the centering to
13
+ * be visible; panels open at the standard side-panel width. */
14
+ export const PANEL_DEFAULT_WIDTH = 420;
15
+ export const EDITOR_DEFAULT_WIDTH = 820;
16
+
17
+ /** `userAdjusted` flips true the first time the tester drags the handle; after
18
+ * that we stop applying per-page defaults so their chosen width sticks. */
19
+ export const layoutState = reactive<{ width: number; userAdjusted: boolean }>({
20
+ width: PANEL_DEFAULT_WIDTH,
21
+ userAdjusted: false,
22
+ });
23
+
24
+ /** Apply a route's preferred default width, unless the tester has taken over. */
25
+ export function applyDefaultWidth(px: number): void {
26
+ if (!layoutState.userAdjusted) {
27
+ setWidth(px);
28
+ }
29
+ }
30
+
31
+ /** Current page breadcrumb shown in the toolbar. Updated by the client router. */
32
+ export const pageState = reactive<{ breadcrumb: string }>({ breadcrumb: "" });
12
33
 
13
34
  function maxWidth(): number {
14
35
  return Math.max(MIN_WIDTH, window.innerWidth);
@@ -22,6 +43,7 @@ export function setWidth(px: number): void {
22
43
  * binds directly to Arrow's `@mousedown` handler signature. */
23
44
  export function startResize(event: Event): void {
24
45
  event.preventDefault();
46
+ layoutState.userAdjusted = true;
25
47
  const startX = (event as MouseEvent).clientX;
26
48
  const startWidth = layoutState.width;
27
49