create-obsidian-arrow 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 (38) hide show
  1. package/README.md +47 -0
  2. package/index.mjs +76 -0
  3. package/package.json +40 -0
  4. package/template/.github/workflows/ci.yml +36 -0
  5. package/template/.husky/pre-commit +2 -0
  6. package/template/AGENTS.md +90 -0
  7. package/template/LICENSE +21 -0
  8. package/template/README.md +116 -0
  9. package/template/_gitignore +8 -0
  10. package/template/biome.json +30 -0
  11. package/template/docs/prompts/agent-setup.md +94 -0
  12. package/template/docs/superpowers/specs/2026-06-29-obsidian-arrow-sandbox-design.md +206 -0
  13. package/template/index.html +19 -0
  14. package/template/package.json +43 -0
  15. package/template/pnpm-lock.yaml +1408 -0
  16. package/template/scripts/install-skills.mjs +47 -0
  17. package/template/scripts/lib/extract-app-css.mjs +60 -0
  18. package/template/scripts/pull-app-css.mjs +90 -0
  19. package/template/skills/arrow-js-obsidian-patterns/SKILL.md +94 -0
  20. package/template/skills/arrow-js-obsidian-templates/SKILL.md +101 -0
  21. package/template/skills/obsidian-arrow-sandbox/SKILL.md +64 -0
  22. package/template/src/components/SettingsPanel.ts +232 -0
  23. package/template/src/data/loadStatus.ts +17 -0
  24. package/template/src/examples/ExamplesIndex.ts +36 -0
  25. package/template/src/examples/registry.ts +26 -0
  26. package/template/src/main.ts +18 -0
  27. package/template/src/router/client.ts +85 -0
  28. package/template/src/router/routeToPage.ts +57 -0
  29. package/template/src/sandbox/frame.ts +35 -0
  30. package/template/src/sandbox/layout.ts +40 -0
  31. package/template/src/sandbox/sandbox.css +125 -0
  32. package/template/src/sandbox/shell.ts +15 -0
  33. package/template/src/sandbox/theme.ts +22 -0
  34. package/template/src/sandbox/toolbar.ts +32 -0
  35. package/template/test/extract-app-css.test.mjs +70 -0
  36. package/template/test/template-footguns.test.mjs +58 -0
  37. package/template/tsconfig.json +13 -0
  38. package/template/vite.config.ts +15 -0
@@ -0,0 +1,232 @@
1
+ import { component, html, reactive } from "@arrow-js/core";
2
+ import type { ArrowTemplate } from "@arrow-js/core";
3
+ import { boundary } from "@arrow-js/framework";
4
+ import { loadStatus } from "../data/loadStatus";
5
+
6
+ /**
7
+ * Baseline Arrow component for the Obsidian sandbox.
8
+ *
9
+ * Built only with Obsidian's own classes (.setting-item, .checkbox-container,
10
+ * .vertical-tab-*) + var(--…) tokens, so it copy-pastes into an ItemView /
11
+ * settings tab. It deliberately exercises the template features that matter:
12
+ *
13
+ * - reactive `${() => …}` vs static `${…}`
14
+ * - attribute sync with false-removal: `disabled="${() => !cond}"` (false ⇒ removed)
15
+ * - property binding: `.checked="${() => …}"`
16
+ * - events: `@click`
17
+ * - keyed lists: `.key(id)` + fine-grained in-place reactivity
18
+ * - an async section via component(asyncFn, { fallback }) wrapped in boundary()
19
+ */
20
+
21
+ interface Feature {
22
+ id: string;
23
+ name: string;
24
+ description: string;
25
+ enabled: boolean;
26
+ }
27
+
28
+ const tabs = [
29
+ { id: "general", label: "General" },
30
+ { id: "advanced", label: "Advanced engine" },
31
+ ] as const;
32
+
33
+ type TabId = (typeof tabs)[number]["id"];
34
+
35
+ const state = reactive({
36
+ activeTab: "general" as TabId,
37
+ developerMode: true,
38
+ pluginName: "Arrow Component",
39
+ lastAction: "—",
40
+ features: [
41
+ {
42
+ id: "live-queue",
43
+ name: "Live queue",
44
+ description: "Stream queue updates into the side panel.",
45
+ enabled: true,
46
+ },
47
+ {
48
+ id: "semantic",
49
+ name: "Semantic search",
50
+ description: "Embed notes for vault-wide similarity search.",
51
+ enabled: false,
52
+ },
53
+ {
54
+ id: "telemetry",
55
+ name: "Anonymous telemetry",
56
+ description: "Share usage metrics to improve the plugin.",
57
+ enabled: false,
58
+ },
59
+ ] as Feature[],
60
+ });
61
+
62
+ const enabledCount = (): number => state.features.filter((f) => f.enabled).length;
63
+
64
+ function rebuildIndex(): void {
65
+ state.lastAction = `Rebuilt index at tick ${performance.now().toFixed(0)}`;
66
+ }
67
+
68
+ /**
69
+ * Reusable Obsidian-style toggle. `enabled` is a getter so the control tracks
70
+ * live state; clicking flips it in place (deep reactivity re-runs only the
71
+ * tracked expressions below — no list re-render).
72
+ */
73
+ const Toggle = (enabled: () => boolean, onToggle: () => void): ArrowTemplate => html`<div
74
+ class="${() => `checkbox-container${enabled() ? " is-enabled" : ""}`}"
75
+ @click="${onToggle}"
76
+ >
77
+ <input type="checkbox" tabindex="0" .checked="${() => enabled()}" />
78
+ </div>`;
79
+
80
+ const generalTab = (): ArrowTemplate => html`
81
+ <div class="setting-item setting-item-heading">
82
+ <div class="setting-item-info">
83
+ <div class="setting-item-name">${() => state.pluginName}</div>
84
+ <div class="setting-item-description">
85
+ ${() => `${enabledCount()} of ${state.features.length} features enabled`}
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ <div class="setting-item">
91
+ <div class="setting-item-info">
92
+ <div class="setting-item-name">Developer sandbox mode</div>
93
+ <div class="setting-item-description">Run isolated component rendering routines.</div>
94
+ </div>
95
+ <div class="setting-item-control">
96
+ ${Toggle(
97
+ () => state.developerMode,
98
+ () => {
99
+ state.developerMode = !state.developerMode;
100
+ }
101
+ )}
102
+ </div>
103
+ </div>
104
+
105
+ ${() =>
106
+ state.features.map((feature) =>
107
+ html`
108
+ <div class="setting-item">
109
+ <div class="setting-item-info">
110
+ <div class="setting-item-name">${feature.name}</div>
111
+ <div class="setting-item-description">${feature.description}</div>
112
+ </div>
113
+ <div class="setting-item-control">
114
+ ${Toggle(
115
+ () => feature.enabled,
116
+ () => {
117
+ feature.enabled = !feature.enabled;
118
+ }
119
+ )}
120
+ </div>
121
+ </div>
122
+ `.key(feature.id)
123
+ )}
124
+
125
+ <div class="setting-item">
126
+ <div class="setting-item-info">
127
+ <div class="setting-item-name">Sandbox state</div>
128
+ </div>
129
+ <div class="setting-item-control">
130
+ <span
131
+ style="${() =>
132
+ `color: ${state.developerMode ? "var(--text-accent)" : "var(--text-error)"}; font-weight: var(--font-semibold);`}"
133
+ >${() => (state.developerMode ? "ONLINE" : "OFFLINE")}</span>
134
+ </div>
135
+ </div>
136
+
137
+ ${boundary(statusCard())}
138
+ `;
139
+
140
+ const advancedTab = (): ArrowTemplate => html`
141
+ <div class="setting-item setting-item-heading">
142
+ <div class="setting-item-info">
143
+ <div class="setting-item-name">Advanced engine</div>
144
+ </div>
145
+ </div>
146
+
147
+ <div class="setting-item">
148
+ <div class="setting-item-info">
149
+ <div class="setting-item-name">Rebuild index</div>
150
+ <div class="setting-item-description">
151
+ ${() =>
152
+ state.developerMode
153
+ ? "Available while developer mode is on."
154
+ : "Enable developer mode to rebuild."}
155
+ </div>
156
+ </div>
157
+ <div class="setting-item-control">
158
+ <button
159
+ class="mod-cta"
160
+ disabled="${() => !state.developerMode}"
161
+ @click="${rebuildIndex}"
162
+ >Rebuild</button>
163
+ </div>
164
+ </div>
165
+
166
+ <div class="setting-item">
167
+ <div class="setting-item-info">
168
+ <div class="setting-item-name">Last action</div>
169
+ <div class="setting-item-description" style="font-family: var(--font-monospace);">
170
+ ${() => state.lastAction}
171
+ </div>
172
+ </div>
173
+ </div>
174
+ `;
175
+
176
+ /** Async component: resolves to a setting row; shows a fallback while pending. */
177
+ const statusCard = component(
178
+ async () => {
179
+ const status = await loadStatus();
180
+ return html`
181
+ <div class="setting-item">
182
+ <div class="setting-item-info">
183
+ <div class="setting-item-name">Connection</div>
184
+ <div class="setting-item-description">${status.detail}</div>
185
+ </div>
186
+ <div class="setting-item-control">
187
+ <span style="color: var(--text-success); font-weight: var(--font-semibold);">
188
+ ${status.label}
189
+ </span>
190
+ </div>
191
+ </div>
192
+ `;
193
+ },
194
+ {
195
+ fallback: html`
196
+ <div class="setting-item">
197
+ <div class="setting-item-info">
198
+ <div class="setting-item-name">Connection</div>
199
+ <div class="setting-item-description">Checking connection…</div>
200
+ </div>
201
+ <div class="setting-item-control">
202
+ <span style="color: var(--text-muted);">…</span>
203
+ </div>
204
+ </div>
205
+ `,
206
+ }
207
+ );
208
+
209
+ export const SettingsPanel = component(
210
+ () => html`
211
+ <div class="oas-settings">
212
+ <div class="vertical-tab-header">
213
+ <div class="vertical-tab-header-group">
214
+ <div class="vertical-tab-header-group-title">Plugin settings</div>
215
+ ${() =>
216
+ tabs.map((tab) =>
217
+ html`<div
218
+ class="${() => `vertical-tab-nav-item${state.activeTab === tab.id ? " is-active" : ""}`}"
219
+ @click="${() => {
220
+ state.activeTab = tab.id;
221
+ }}"
222
+ >${tab.label}</div>`.key(tab.id)
223
+ )}
224
+ </div>
225
+ </div>
226
+
227
+ <div class="vertical-tab-content">
228
+ ${() => (state.activeTab === "general" ? generalTab() : advancedTab())}
229
+ </div>
230
+ </div>
231
+ `
232
+ );
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Stand-in async data source for the boundary() demo. In the real plugin this
3
+ * would be an RPC call (session scan, model list, connection probe). The
4
+ * artificial delay lets us actually see the async fallback render.
5
+ */
6
+ export interface StatusInfo {
7
+ label: string;
8
+ detail: string;
9
+ }
10
+
11
+ export async function loadStatus(): Promise<StatusInfo> {
12
+ await new Promise((resolve) => setTimeout(resolve, 900));
13
+ return {
14
+ label: "Connected",
15
+ detail: "pi daemon reachable · 12ms latency · 3 vaults synced",
16
+ };
17
+ }
@@ -0,0 +1,36 @@
1
+ import { html } from "@arrow-js/core";
2
+ import type { ArrowTemplate } from "@arrow-js/core";
3
+ import type { Example } from "./registry";
4
+
5
+ /**
6
+ * Landing page at "/" — an Obsidian-styled list of the available example
7
+ * components, each linking to its own route. Plain anchors (full reloads) keep
8
+ * the router trivial; the sandbox doesn't need SPA navigation.
9
+ */
10
+ export const ExamplesIndex = (items: Example[]): ArrowTemplate => html`
11
+ <div class="oas-settings">
12
+ <div class="setting-item setting-item-heading">
13
+ <div class="setting-item-info">
14
+ <div class="setting-item-name">Examples</div>
15
+ <div class="setting-item-description">
16
+ Component demos rendered with real Obsidian styling.
17
+ </div>
18
+ </div>
19
+ </div>
20
+ ${items.map((example) =>
21
+ html`
22
+ <div class="setting-item">
23
+ <div class="setting-item-info">
24
+ <div class="setting-item-name">
25
+ <a href="${example.path}">${example.label}</a>
26
+ </div>
27
+ <div class="setting-item-description">${example.description}</div>
28
+ </div>
29
+ <div class="setting-item-control">
30
+ <a class="mod-cta oas-open-link" href="${example.path}">Open</a>
31
+ </div>
32
+ </div>
33
+ `.key(example.path)
34
+ )}
35
+ </div>
36
+ `;
@@ -0,0 +1,26 @@
1
+ import type { ArrowExpression } from "@arrow-js/core";
2
+ import { SettingsPanel } from "../components/SettingsPanel";
3
+
4
+ /**
5
+ * Registry of example components, keyed by route path. Add a new demo here and
6
+ * it shows up on the index page and at its own path automatically.
7
+ */
8
+ export interface Example {
9
+ path: string;
10
+ label: string;
11
+ description: string;
12
+ view: () => ArrowExpression;
13
+ }
14
+
15
+ export const examples: Example[] = [
16
+ {
17
+ path: "/example",
18
+ label: "Settings panel",
19
+ description: "Vertical tabs, toggles, a keyed list, and an async boundary() section.",
20
+ view: () => SettingsPanel(),
21
+ },
22
+ ];
23
+
24
+ export function findExample(path: string): Example | undefined {
25
+ return examples.find((example) => example.path === path);
26
+ }
@@ -0,0 +1,18 @@
1
+ // Side-effect import installs the framework runtime (async components + boundary).
2
+ // This is the one extra line a plugin would add to adopt @arrow-js/framework.
3
+ import "@arrow-js/framework";
4
+
5
+ import { startRouter } from "./router/client";
6
+ import { applyTheme } from "./sandbox/theme";
7
+ import "./sandbox/sandbox.css";
8
+
9
+ applyTheme();
10
+
11
+ const root = document.getElementById("app");
12
+ if (!root) {
13
+ throw new Error("Sandbox mount point #app not found.");
14
+ }
15
+
16
+ // Routes resolve through routeToPage() and mount via template(container) —
17
+ // the same call an Obsidian ItemView.onOpen() makes.
18
+ startRouter(root);
@@ -0,0 +1,85 @@
1
+ import { Frame } from "../sandbox/frame";
2
+ import { Shell } from "../sandbox/shell";
3
+ import { routeToPage } from "./routeToPage";
4
+
5
+ /**
6
+ * Client router. Prefers the native Navigation API (single `navigate` event
7
+ * stream, reliable history traversal) and falls back to the History API +
8
+ * click interception for browsers without it. Both paths resolve through the
9
+ * one `routeToPage()` and re-mount the Frame into the root.
10
+ *
11
+ * Minimal Navigation API typings — TS lib coverage varies by version, so we
12
+ * structurally type only the members we touch instead of relying on lib.dom.
13
+ */
14
+ interface NavigateEventLike {
15
+ canIntercept: boolean;
16
+ hashChange: boolean;
17
+ downloadRequest: string | null;
18
+ destination: { url: string };
19
+ intercept(options: { handler: () => Promise<void> | void }): void;
20
+ }
21
+
22
+ interface NavigationLike {
23
+ addEventListener(type: "navigate", listener: (event: NavigateEventLike) => void): void;
24
+ }
25
+
26
+ function getNavigation(): NavigationLike | undefined {
27
+ return (window as unknown as { navigation?: NavigationLike }).navigation;
28
+ }
29
+
30
+ export function startRouter(root: HTMLElement): void {
31
+ const render = (url: string): void => {
32
+ const page = routeToPage(url);
33
+ document.title = page.title;
34
+ root.replaceChildren();
35
+ Shell(Frame(page.title, page.view))(root);
36
+ };
37
+
38
+ render(window.location.href);
39
+
40
+ const navigation = getNavigation();
41
+ if (navigation) {
42
+ navigation.addEventListener("navigate", (event) => {
43
+ if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) {
44
+ return;
45
+ }
46
+ const destination = new URL(event.destination.url);
47
+ if (destination.origin !== window.location.origin) {
48
+ return;
49
+ }
50
+ event.intercept({
51
+ handler: () => {
52
+ render(destination.href);
53
+ },
54
+ });
55
+ });
56
+ return;
57
+ }
58
+
59
+ // History API fallback for browsers without the Navigation API.
60
+ document.addEventListener("click", (event) => {
61
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey) {
62
+ return;
63
+ }
64
+ const target = event.target as Element | null;
65
+ const link = target?.closest("a");
66
+ if (!link) {
67
+ return;
68
+ }
69
+ const href = link.getAttribute("href");
70
+ if (!href || link.target === "_blank" || link.hasAttribute("download")) {
71
+ return;
72
+ }
73
+ const destination = new URL(href, window.location.origin);
74
+ if (destination.origin !== window.location.origin) {
75
+ return;
76
+ }
77
+ event.preventDefault();
78
+ window.history.pushState({}, "", destination.href);
79
+ render(destination.href);
80
+ });
81
+
82
+ window.addEventListener("popstate", () => {
83
+ render(window.location.href);
84
+ });
85
+ }
@@ -0,0 +1,57 @@
1
+ import { html } from "@arrow-js/core";
2
+ import type { ArrowExpression } from "@arrow-js/core";
3
+ import { ExamplesIndex } from "../examples/ExamplesIndex";
4
+ import { examples, findExample } from "../examples/registry";
5
+
6
+ /**
7
+ * Single route resolver, shared by every entry point. Returns the page status,
8
+ * title (metadata), and Arrow view together — the same shape the Arrow Vite
9
+ * scaffold uses, so a future SSR/hydration lane can call this identically on
10
+ * both server and client. The client router (./client.ts) wraps the view in the
11
+ * sandbox Frame and sets document.title from this.
12
+ */
13
+ export interface Page {
14
+ status: number;
15
+ title: string;
16
+ view: ArrowExpression;
17
+ }
18
+
19
+ const APP_NAME = "Arrow Sandbox";
20
+
21
+ export function routeToPage(url: string): Page {
22
+ const { pathname } = new URL(url, window.location.origin);
23
+
24
+ if (pathname === "/" || pathname === "") {
25
+ return {
26
+ status: 200,
27
+ title: `Examples · ${APP_NAME}`,
28
+ view: ExamplesIndex(examples),
29
+ };
30
+ }
31
+
32
+ const match = findExample(pathname);
33
+ if (match) {
34
+ return {
35
+ status: 200,
36
+ title: `${match.label} · ${APP_NAME}`,
37
+ view: match.view(),
38
+ };
39
+ }
40
+
41
+ return {
42
+ status: 404,
43
+ title: `Not found · ${APP_NAME}`,
44
+ view: html`
45
+ <div class="oas-settings">
46
+ <div class="setting-item setting-item-heading">
47
+ <div class="setting-item-info">
48
+ <div class="setting-item-name">Not found</div>
49
+ <div class="setting-item-description">
50
+ No route for <code>${pathname}</code>. <a href="/">Back to examples</a>.
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ `,
56
+ };
57
+ }
@@ -0,0 +1,35 @@
1
+ import { html } from "@arrow-js/core";
2
+ import type { ArrowExpression, ArrowTemplate } from "@arrow-js/core";
3
+ import { layoutState, startResize } from "./layout";
4
+ import { themeState, toggleTheme } from "./theme";
5
+
6
+ /**
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.
12
+ *
13
+ * Sandbox chrome only — the route view mounted inside is what ports to a plugin.
14
+ */
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>
34
+ </div>
35
+ `;
@@ -0,0 +1,40 @@
1
+ import { reactive } from "@arrow-js/core";
2
+
3
+ /**
4
+ * Sandbox-only panel sizing. Obsidian side panels are user-resizable, so we let
5
+ * the tester adjust the pane width (height stays pinned to the window). Sandbox
6
+ * chrome — not ported into the plugin.
7
+ */
8
+ export const MIN_WIDTH = 240;
9
+ export const WIDTH_PRESETS = [280, 360, 480, 640];
10
+
11
+ export const layoutState = reactive<{ width: number }>({ width: 420 });
12
+
13
+ function maxWidth(): number {
14
+ return Math.max(MIN_WIDTH, window.innerWidth);
15
+ }
16
+
17
+ export function setWidth(px: number): void {
18
+ layoutState.width = Math.min(maxWidth(), Math.max(MIN_WIDTH, Math.round(px)));
19
+ }
20
+
21
+ /** Start a drag-resize from the panel's edge handle. Typed as `Event` so it
22
+ * binds directly to Arrow's `@mousedown` handler signature. */
23
+ export function startResize(event: Event): void {
24
+ event.preventDefault();
25
+ const startX = (event as MouseEvent).clientX;
26
+ const startWidth = layoutState.width;
27
+
28
+ const onMove = (move: MouseEvent): void => {
29
+ setWidth(startWidth + (move.clientX - startX));
30
+ };
31
+ const onUp = (): void => {
32
+ document.removeEventListener("mousemove", onMove);
33
+ document.removeEventListener("mouseup", onUp);
34
+ document.body.style.userSelect = "";
35
+ };
36
+
37
+ document.body.style.userSelect = "none";
38
+ document.addEventListener("mousemove", onMove);
39
+ document.addEventListener("mouseup", onUp);
40
+ }
@@ -0,0 +1,125 @@
1
+ /*
2
+ * Sandbox-only chrome. NOT ported into the plugin.
3
+ *
4
+ * Per the arrow-js-obsidian CSS-specificity lesson, every custom rule is scoped
5
+ * under a container class + element type so it can't lose to Obsidian's global
6
+ * rules (e.g. `button:not(.clickable-icon)`) and can't leak. All values use
7
+ * Obsidian tokens so the sandbox tracks the active theme.
8
+ */
9
+
10
+ html,
11
+ body {
12
+ margin: 0;
13
+ height: 100%;
14
+ background: var(--background-primary);
15
+ color: var(--text-normal);
16
+ font-family: var(--font-interface);
17
+ }
18
+
19
+ #app {
20
+ height: 100vh;
21
+ }
22
+
23
+ /* Shell: width-control toolbar on top, pane stage below. */
24
+ .oas-shell {
25
+ display: flex;
26
+ flex-direction: column;
27
+ height: 100vh;
28
+ }
29
+
30
+ .oas-toolbar {
31
+ display: flex;
32
+ align-items: center;
33
+ gap: var(--size-4-2);
34
+ padding: var(--size-4-1) var(--size-4-3);
35
+ background: var(--background-secondary);
36
+ border-bottom: 1px solid var(--background-modifier-border);
37
+ color: var(--text-muted);
38
+ font-size: var(--font-ui-small);
39
+ }
40
+
41
+ .oas-toolbar .oas-toolbar-label {
42
+ font-weight: var(--font-medium);
43
+ }
44
+
45
+ .oas-toolbar .oas-width-range {
46
+ flex: 0 1 220px;
47
+ }
48
+
49
+ .oas-toolbar .oas-width-readout {
50
+ min-width: 52px;
51
+ font-family: var(--font-monospace);
52
+ color: var(--text-normal);
53
+ }
54
+
55
+ .oas-toolbar button.oas-preset {
56
+ height: var(--size-4-5);
57
+ padding: 0 var(--size-4-2);
58
+ font-size: var(--font-ui-smaller);
59
+ }
60
+
61
+ /* Stage: left-aligned so the edge drag handle resizes width 1:1. */
62
+ .oas-stage {
63
+ flex: 1;
64
+ display: flex;
65
+ justify-content: flex-start;
66
+ overflow: hidden;
67
+ background: var(--background-secondary);
68
+ }
69
+
70
+ .oas-frame.workspace-leaf {
71
+ position: relative;
72
+ flex: 0 0 auto;
73
+ height: 100%;
74
+ min-width: 240px;
75
+ display: flex;
76
+ flex-direction: column;
77
+ background: var(--background-primary);
78
+ border-right: 1px solid var(--background-modifier-border);
79
+ }
80
+
81
+ .oas-frame .oas-view-content {
82
+ flex: 1;
83
+ overflow-y: auto;
84
+ padding: var(--size-4-3);
85
+ }
86
+
87
+ .oas-frame .view-header {
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: space-between;
91
+ }
92
+
93
+ .oas-frame .oas-view-header-left {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: var(--size-4-2);
97
+ }
98
+
99
+ .oas-frame .view-header .oas-theme-toggle,
100
+ .oas-frame .view-header .oas-home {
101
+ font-size: var(--font-ui-medium);
102
+ line-height: 1;
103
+ text-decoration: none;
104
+ }
105
+
106
+ .oas-frame a.oas-open-link {
107
+ text-decoration: none;
108
+ display: inline-flex;
109
+ align-items: center;
110
+ }
111
+
112
+ /* Drag-to-resize handle on the pane's right edge (Obsidian-like). */
113
+ .oas-frame .oas-resize-handle {
114
+ position: absolute;
115
+ top: 0;
116
+ right: 0;
117
+ width: 6px;
118
+ height: 100%;
119
+ cursor: ew-resize;
120
+ }
121
+
122
+ .oas-frame .oas-resize-handle:hover {
123
+ background: var(--interactive-accent);
124
+ opacity: 0.5;
125
+ }
@@ -0,0 +1,15 @@
1
+ import { html } from "@arrow-js/core";
2
+ import type { ArrowExpression, ArrowTemplate } from "@arrow-js/core";
3
+ import { Toolbar } from "./toolbar";
4
+
5
+ /**
6
+ * Sandbox shell: the width-control toolbar plus a stage that holds the pane.
7
+ * The Frame (and the component under test) mount inside the stage. Sandbox
8
+ * chrome — only the pane contents port into a plugin.
9
+ */
10
+ export const Shell = (content: ArrowExpression): ArrowTemplate => html`
11
+ <div class="oas-shell">
12
+ ${Toolbar()}
13
+ <div class="oas-stage">${content}</div>
14
+ </div>
15
+ `;