@jarenjs/studio 0.34.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.
- package/README.md +72 -0
- package/dist/types/assemble.d.ts +67 -0
- package/dist/types/component/editor.d.ts +68 -0
- package/dist/types/component/host.d.ts +45 -0
- package/dist/types/component/index.d.ts +262 -0
- package/dist/types/component/view.d.ts +232 -0
- package/dist/types/component/viewmodel.d.ts +22 -0
- package/dist/types/errors.d.ts +31 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/project.d.ts +31 -0
- package/dist/types/validate.d.ts +48 -0
- package/docs/PROJECT-FORMAT.md +112 -0
- package/package.json +69 -0
- package/schemas/jaren-project.draft-07.schema.json +41 -0
- package/schemas/jaren-project.schema.json +47 -0
- package/src/assemble.js +127 -0
- package/src/component/editor.js +59 -0
- package/src/component/host.js +53 -0
- package/src/component/index.js +47 -0
- package/src/component/view.js +198 -0
- package/src/component/viewmodel.js +150 -0
- package/src/errors.js +33 -0
- package/src/index.js +20 -0
- package/src/project.js +80 -0
- package/src/validate.js +191 -0
- package/styles/studio.css +227 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/studio` — the ENGINE (part one of the two-layer
|
|
4
|
+
* package). Headless: it parses a `jaren-project` document, validates
|
|
5
|
+
* each file against its own kind grammar, assembles the runnable
|
|
6
|
+
* artifacts, and classifies a change as structural vs. state-only. It
|
|
7
|
+
* knows the suite's grammars (validate/json/app/flow/db) but nothing of
|
|
8
|
+
* the DOM, `@jarenjs/view` or `@jarenjs/app`'s runtime — the component
|
|
9
|
+
* layer (`./component`) imports the engine, never the reverse.
|
|
10
|
+
*
|
|
11
|
+
* The project is a THIN envelope over typed files; there is deliberately
|
|
12
|
+
* no single composed meta-schema, so a data file may use host-registered
|
|
13
|
+
* operators the closed grammars forbid — the per-file validators are the
|
|
14
|
+
* honest boundary.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { KINDS, LAYOUT_DEFAULT, parseProject, fileOf } from './project.js';
|
|
18
|
+
export { validateFile } from './validate.js';
|
|
19
|
+
export { assembleArtifacts, classifyChange, describe } from './assemble.js';
|
|
20
|
+
export { STUDIO_CODES, StudioError } from './errors.js';
|
package/src/project.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The project document model: the closed file-kind vocabulary, the
|
|
4
|
+
* frozen IDE `layout` shape, and `parseProject` — validate a candidate
|
|
5
|
+
* against the `jaren-project` envelope schema, reject duplicate file
|
|
6
|
+
* names, and return a NORMALIZED, frozen project (layout defaulted,
|
|
7
|
+
* `active` resolved to a real file). Only the ENVELOPE is gated here; a
|
|
8
|
+
* file's `text` is a string until its kind validator runs (`validate.js`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
12
|
+
import projectSchema from '../schemas/jaren-project.schema.json' with { type: 'json' };
|
|
13
|
+
import { StudioError } from './errors.js';
|
|
14
|
+
|
|
15
|
+
/** The closed set of file kinds (matches the schema `kind` enum). */
|
|
16
|
+
export const KINDS = Object.freeze([
|
|
17
|
+
'app', 'jslt', 'query', 'state', 'data', 'schema', 'fsm', 'dag', 'model',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** The default IDE layout — the frozen `{ mode, ratio, autorun }` shape
|
|
21
|
+
* that rides the share link and the eject, so it must not drift. */
|
|
22
|
+
export const LAYOUT_DEFAULT = Object.freeze({ mode: 'classic', ratio: 0.5, autorun: true });
|
|
23
|
+
|
|
24
|
+
const validateEnvelope = new JarenValidator({ skipErrors: false, collectErrors: true })
|
|
25
|
+
.compile(projectSchema);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse and normalize a project: JSON text or an object in; a frozen,
|
|
29
|
+
* normalized project out. A malformed envelope is `JS0001`; a duplicate
|
|
30
|
+
* file name is `JS0002`.
|
|
31
|
+
* @param {string | object} input
|
|
32
|
+
* @returns {any}
|
|
33
|
+
*/
|
|
34
|
+
export function parseProject(input) {
|
|
35
|
+
let doc;
|
|
36
|
+
if (typeof input === 'string') {
|
|
37
|
+
try { doc = JSON.parse(input); }
|
|
38
|
+
catch (cause) {
|
|
39
|
+
throw new StudioError('JS0001',
|
|
40
|
+
`the project is not valid JSON: ${String(/** @type {any} */ (cause)?.message ?? cause)}`,
|
|
41
|
+
'', { cause });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else doc = input;
|
|
45
|
+
|
|
46
|
+
const outcome = validateEnvelope(doc);
|
|
47
|
+
const valid = typeof outcome === 'object' && outcome !== null ? outcome.valid : outcome === true;
|
|
48
|
+
if (!valid) {
|
|
49
|
+
const errors = (typeof outcome === 'object' && outcome !== null ? outcome.errors : null) ?? [];
|
|
50
|
+
throw new StudioError('JS0001',
|
|
51
|
+
`the project does not validate against jaren-project (${errors.length} error${errors.length === 1 ? '' : 's'})`,
|
|
52
|
+
errors[0]?.instancePath ?? '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const names = new Set();
|
|
56
|
+
for (const file of doc.files) {
|
|
57
|
+
if (names.has(file.name))
|
|
58
|
+
throw new StudioError('JS0002', `two files are named '${file.name}'`, '/files');
|
|
59
|
+
names.add(file.name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const active = typeof doc.active === 'string' && names.has(doc.active)
|
|
63
|
+
? doc.active
|
|
64
|
+
: (doc.files[0]?.name ?? null);
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
project: doc.project,
|
|
67
|
+
files: Object.freeze(doc.files.map((f) => Object.freeze({ ...f }))),
|
|
68
|
+
active,
|
|
69
|
+
layout: Object.freeze({ ...LAYOUT_DEFAULT, ...doc.layout }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The file with this name, or `null`.
|
|
75
|
+
* @param {any} project
|
|
76
|
+
* @param {string} name
|
|
77
|
+
*/
|
|
78
|
+
export function fileOf(project, name) {
|
|
79
|
+
return project.files.find((f) => f.name === name) ?? null;
|
|
80
|
+
}
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Per-file validation — the honest heart of the per-file design.
|
|
4
|
+
* `validateFile(file)` dispatches on the file's kind and validates its
|
|
5
|
+
* `text` against THAT kind's grammar (never one composed mega-schema):
|
|
6
|
+
*
|
|
7
|
+
* - `app` → the composed jaren-app meta-schema (which `$ref`s the
|
|
8
|
+
* query + JSLT grammars) PLUS a headless render audit (a
|
|
9
|
+
* document that validates but throws on its first frame is
|
|
10
|
+
* still broken);
|
|
11
|
+
* - `jslt` / `query` → COMPILED by the engine WITH the operator registry,
|
|
12
|
+
* so host-registered operators ($npv, $sqrt) validate and a
|
|
13
|
+
* real error comes back as its own coded `JQ`/`JT` code with
|
|
14
|
+
* a docPath — the closed grammar would reject the operators;
|
|
15
|
+
* - `fsm` / `dag` / `model` → their published grammar (structural);
|
|
16
|
+
* - `schema` → compiled as a JSON Schema (is it well-formed?);
|
|
17
|
+
* - `state` / `data` → any JSON (structural only).
|
|
18
|
+
*
|
|
19
|
+
* Every result is `{ valid, kind, total, errors: [{ code, message,
|
|
20
|
+
* docPath }] }` — the shape the IDE's docked error strip reads.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createWeakCache } from '@jarenjs/core/cache';
|
|
24
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
25
|
+
import { createTypeTestCompiler } from '@jarenjs/validate/query';
|
|
26
|
+
import { compileJsonQuery } from '@jarenjs/json';
|
|
27
|
+
import {
|
|
28
|
+
compileJsltStylesheet, createJsltRegistry, mathPack, financePack, statsPack,
|
|
29
|
+
} from '@jarenjs/json/jslt';
|
|
30
|
+
|
|
31
|
+
import appSchema from '@jarenjs/app/schemas/jaren-app.schema.json' with { type: 'json' };
|
|
32
|
+
import querySchema from '@jarenjs/json/schemas/jaren-query.schema.json' with { type: 'json' };
|
|
33
|
+
import jsltSchema from '@jarenjs/json/schemas/jaren-jslt.schema.json' with { type: 'json' };
|
|
34
|
+
import fsmSchema from '@jarenjs/flow/schemas/jaren-fsm.schema.json' with { type: 'json' };
|
|
35
|
+
import dagSchema from '@jarenjs/flow/schemas/jaren-dag.schema.json' with { type: 'json' };
|
|
36
|
+
import modelSchema from '@jarenjs/db/schemas/jaren-model.schema.json' with { type: 'json' };
|
|
37
|
+
|
|
38
|
+
/** Errors kept per report: enough to repair, bounded for the IDE. */
|
|
39
|
+
const MAX_ERRORS = 20;
|
|
40
|
+
|
|
41
|
+
const compileTypeTest = createTypeTestCompiler();
|
|
42
|
+
|
|
43
|
+
// the studio mounts the built-in operator packs so a query/jslt/data file
|
|
44
|
+
// may compute with $npv/$sqrt/$mean/… (host opt-in; a plain compile would
|
|
45
|
+
// reject them). A host embedding the studio can substitute its own via
|
|
46
|
+
// `validateFile(file, { operators })`.
|
|
47
|
+
const defaultRegistry = createJsltRegistry().use(mathPack).use(financePack).use(statsPack);
|
|
48
|
+
const defaultRegistryOptions = defaultRegistry.toOptions();
|
|
49
|
+
|
|
50
|
+
const validateApp = new JarenValidator({ skipErrors: false, collectErrors: true })
|
|
51
|
+
.addSchema(querySchema).addSchema(jsltSchema).compile(appSchema);
|
|
52
|
+
const validateFsm = new JarenValidator({ skipErrors: false, collectErrors: true })
|
|
53
|
+
.addSchema(querySchema).compile(fsmSchema);
|
|
54
|
+
const validateDag = new JarenValidator({ skipErrors: false, collectErrors: true })
|
|
55
|
+
.addSchema(querySchema).addSchema(jsltSchema).compile(dagSchema);
|
|
56
|
+
const validateModel = new JarenValidator({ skipErrors: false, collectErrors: true })
|
|
57
|
+
.compile(modelSchema);
|
|
58
|
+
|
|
59
|
+
/** @param {string} kind */
|
|
60
|
+
const ok = (kind) => ({ valid: true, kind, total: 0, errors: [] });
|
|
61
|
+
|
|
62
|
+
/** Normalize a JarenValidator failure into the studio error shape. */
|
|
63
|
+
function schemaResult(kind, outcome) {
|
|
64
|
+
const valid = typeof outcome === 'object' && outcome !== null ? outcome.valid : outcome === true;
|
|
65
|
+
if (valid) return ok(kind);
|
|
66
|
+
const raw = (typeof outcome === 'object' && outcome !== null ? outcome.errors : null) ?? [];
|
|
67
|
+
return {
|
|
68
|
+
valid: false, kind, total: raw.length,
|
|
69
|
+
errors: raw.slice(0, MAX_ERRORS).map((e) => ({
|
|
70
|
+
code: null, message: e.message ?? 'invalid', docPath: e.instancePath ?? '',
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Run a compile and surface its throw as one coded error, or pass. */
|
|
76
|
+
function compileResult(kind, run) {
|
|
77
|
+
try {
|
|
78
|
+
run();
|
|
79
|
+
return ok(kind);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const e = /** @type {any} */ (err);
|
|
83
|
+
return {
|
|
84
|
+
valid: false, kind, total: 1,
|
|
85
|
+
errors: [{ code: e?.code ?? null, message: String(e?.message ?? err), docPath: e?.docPath }],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A headless render audit for an `app` document: compile the JSLT view
|
|
92
|
+
* and render its first frame over the state. A view that throws is a
|
|
93
|
+
* broken document even when the meta-schema passed it.
|
|
94
|
+
* @param {any} doc
|
|
95
|
+
*/
|
|
96
|
+
function auditAppView(doc) {
|
|
97
|
+
try {
|
|
98
|
+
compileJsltStylesheet(doc.view, { compileTypeTest, memo: false, ...defaultRegistryOptions })(doc.state ?? {});
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return `the view failed to render its first frame: ${String(/** @type {any} */ (err)?.message ?? err)}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verdicts keyed by the FILE OBJECT: validating an `app` compiles its view
|
|
108
|
+
* and renders a first frame, so re-deriving the IDE view model on every
|
|
109
|
+
* render (a theme toggle, a keystroke elsewhere) would recompile the whole
|
|
110
|
+
* project — measured at ~110ms for a charts app. Files are immutable here
|
|
111
|
+
* (every edit lands as a fresh object through the patch engine, and an
|
|
112
|
+
* untouched file keeps its identity), so reference identity is a sound —
|
|
113
|
+
* and free — cache key. The inner map keys the registry, because the same
|
|
114
|
+
* file validates differently under a different operator vocabulary.
|
|
115
|
+
* @type {ReturnType<typeof createWeakCache<object, Map<any, any>>>}
|
|
116
|
+
*/
|
|
117
|
+
const verdicts = createWeakCache();
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate ONE file against its kind's grammar. Memoized on the file's
|
|
121
|
+
* identity; pass a fresh object to force a re-check.
|
|
122
|
+
* @param {{ name?: string, kind: string, text: string }} file
|
|
123
|
+
* @param {{ operators?: { toOptions: () => any } }} [options] - a host
|
|
124
|
+
* operator registry for the `jslt`/`query` kinds (defaults to the
|
|
125
|
+
* built-in math/finance/stats packs)
|
|
126
|
+
* @returns {{ valid: boolean, kind: string, total: number,
|
|
127
|
+
* errors: Array<{ code: string | null, message: string, docPath?: string }> }}
|
|
128
|
+
*/
|
|
129
|
+
export function validateFile(file, options = {}) {
|
|
130
|
+
if (file === null || typeof file !== 'object') return validateFileUncached(file, options);
|
|
131
|
+
const byRegistry = verdicts.getOrCreate(file, () => new Map());
|
|
132
|
+
const registryKey = options.operators ?? null;
|
|
133
|
+
let verdict = byRegistry.get(registryKey);
|
|
134
|
+
if (verdict === undefined) {
|
|
135
|
+
verdict = validateFileUncached(file, options);
|
|
136
|
+
byRegistry.set(registryKey, verdict);
|
|
137
|
+
}
|
|
138
|
+
return verdict;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The validation itself — see `validateFile`, which memoizes it.
|
|
143
|
+
* @param {{ name?: string, kind: string, text: string }} file
|
|
144
|
+
* @param {{ operators?: { toOptions: () => any } }} [options]
|
|
145
|
+
*/
|
|
146
|
+
function validateFileUncached(file, options = {}) {
|
|
147
|
+
const kind = file.kind;
|
|
148
|
+
const registryOptions = options.operators ? options.operators.toOptions() : defaultRegistryOptions;
|
|
149
|
+
|
|
150
|
+
let doc;
|
|
151
|
+
try { doc = JSON.parse(file.text); }
|
|
152
|
+
catch (err) {
|
|
153
|
+
return {
|
|
154
|
+
valid: false, kind, total: 1,
|
|
155
|
+
errors: [{ code: null, message: `not valid JSON: ${String(/** @type {any} */ (err)?.message ?? err)}`, docPath: '' }],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
switch (kind) {
|
|
160
|
+
case 'state':
|
|
161
|
+
case 'data':
|
|
162
|
+
return ok(kind); // any JSON value is a valid state/data file
|
|
163
|
+
case 'app': {
|
|
164
|
+
const structural = schemaResult(kind, validateApp(doc));
|
|
165
|
+
if (!structural.valid) return structural;
|
|
166
|
+
const problem = auditAppView(doc);
|
|
167
|
+
return problem === null
|
|
168
|
+
? structural
|
|
169
|
+
: { valid: false, kind, total: 1, errors: [{ code: null, message: problem, docPath: '/view' }] };
|
|
170
|
+
}
|
|
171
|
+
case 'jslt':
|
|
172
|
+
return compileResult(kind, () => compileJsltStylesheet(doc, { compileTypeTest, ...registryOptions }));
|
|
173
|
+
case 'query':
|
|
174
|
+
return compileResult(kind, () => compileJsonQuery(doc, { compileTypeTest, ...registryOptions }));
|
|
175
|
+
case 'fsm':
|
|
176
|
+
return schemaResult(kind, validateFsm(doc));
|
|
177
|
+
case 'dag':
|
|
178
|
+
return schemaResult(kind, validateDag(doc));
|
|
179
|
+
case 'model':
|
|
180
|
+
return schemaResult(kind, validateModel(doc));
|
|
181
|
+
case 'schema':
|
|
182
|
+
// a JSON Schema is a boolean or an object; anything else is not a
|
|
183
|
+
// schema at all (the compiler is otherwise lenient about a schema's
|
|
184
|
+
// internals — the studio is a project IDE, not a schema linter)
|
|
185
|
+
if (typeof doc !== 'boolean' && (doc === null || typeof doc !== 'object' || Array.isArray(doc)))
|
|
186
|
+
return { valid: false, kind, total: 1, errors: [{ code: null, message: 'a JSON Schema must be an object or a boolean', docPath: '' }] };
|
|
187
|
+
return compileResult(kind, () => new JarenValidator().compile(doc));
|
|
188
|
+
default:
|
|
189
|
+
return { valid: false, kind, total: 1, errors: [{ code: null, message: `unknown file kind '${kind}'`, docPath: '' }] };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/* @jarenjs/studio — the IDE shell stylesheet.
|
|
2
|
+
*
|
|
3
|
+
* DESIGN.md-conformant: minmax(0,1fr) tracks with min-width:0 on every
|
|
4
|
+
* scrollable child, the established 1024/760 breakpoints and scroll-strip
|
|
5
|
+
* mobile pattern, the --space-N and --radius-N scales and theme tokens. The
|
|
6
|
+
* ONLY raw-hex family is the kind-badge palette — concrete constants
|
|
7
|
+
* (like the chart palettes), never aliased status tokens, and strictly
|
|
8
|
+
* blue / cyan / slate / green / amber (no purple, no pink). Colours are
|
|
9
|
+
* duplicated for .dark; a theme.js constants↔CSS-fallback sync pair is a
|
|
10
|
+
* later concern.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
.jstudio {
|
|
14
|
+
display: grid;
|
|
15
|
+
gap: var(--space-3, 0.75rem);
|
|
16
|
+
--js-ratio: 0.5;
|
|
17
|
+
/* rail | editor (the ratio share) | splitter | stage (the rest) — the
|
|
18
|
+
splitter widget drives --js-ratio live; the stage's 1fr absorbs the
|
|
19
|
+
gap/rounding so the panes stay proportional */
|
|
20
|
+
grid-template-columns: 11rem minmax(0, calc((100% - 12rem) * var(--js-ratio))) auto minmax(0, 1fr);
|
|
21
|
+
grid-template-areas:
|
|
22
|
+
"penbar penbar penbar penbar"
|
|
23
|
+
"rail editor split stage";
|
|
24
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
25
|
+
align-items: stretch;
|
|
26
|
+
min-height: 24rem;
|
|
27
|
+
}
|
|
28
|
+
.jstudio[data-mode="right"] { grid-template-areas: "penbar penbar penbar penbar" "rail stage split editor"; }
|
|
29
|
+
.jstudio[data-mode="top"] {
|
|
30
|
+
grid-template-columns: 11rem minmax(0, 1fr);
|
|
31
|
+
grid-template-areas: "penbar penbar" "rail editor" "rail stage";
|
|
32
|
+
grid-template-rows: auto minmax(0, 1fr) minmax(0, 1fr);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.js-penbar {
|
|
36
|
+
grid-area: penbar; display: flex; align-items: center; gap: var(--space-2, 0.5rem);
|
|
37
|
+
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
38
|
+
border: 1px solid var(--border, #e2e8f0); border-radius: var(--radius-sm, 6px);
|
|
39
|
+
background: var(--surface, #fff);
|
|
40
|
+
}
|
|
41
|
+
.js-penname { color: var(--fg, #0f172a); }
|
|
42
|
+
.js-savestate { color: var(--muted, #64748b); font-size: 0.8rem; }
|
|
43
|
+
.js-spacer { flex: 1; }
|
|
44
|
+
.js-filecount { font-size: 0.8rem; }
|
|
45
|
+
|
|
46
|
+
.js-rail { grid-area: rail; min-width: 0; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
|
47
|
+
.js-editor { grid-area: editor; min-width: 0; display: flex; flex-direction: column; }
|
|
48
|
+
.js-stage { grid-area: stage; min-width: 0; display: flex; flex-direction: column; }
|
|
49
|
+
|
|
50
|
+
/* ——— file rail ——— */
|
|
51
|
+
.js-file {
|
|
52
|
+
display: flex; align-items: center; gap: var(--space-2, 0.5rem);
|
|
53
|
+
width: 100%; border: none; background: transparent; cursor: pointer;
|
|
54
|
+
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
55
|
+
border-radius: var(--radius-sm, 6px); border-left: 2px solid transparent;
|
|
56
|
+
color: var(--muted, #64748b); font-size: 0.9rem; text-align: left;
|
|
57
|
+
}
|
|
58
|
+
.js-file.active { color: var(--fg, #0f172a); background: var(--accent-soft, #eff6ff); border-left-color: var(--accent, #2563eb); }
|
|
59
|
+
.js-file-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
60
|
+
.js-file-warn { color: var(--warn, #b45309); margin-left: auto; }
|
|
61
|
+
|
|
62
|
+
/* a row is the select button + a delete affordance */
|
|
63
|
+
.js-file-row { display: flex; align-items: center; gap: 2px; }
|
|
64
|
+
.js-file-row .js-file { flex: 1; }
|
|
65
|
+
.js-file-del {
|
|
66
|
+
flex: 0 0 auto; border: none; background: transparent; cursor: pointer;
|
|
67
|
+
color: var(--muted, #64748b); font-size: 1rem; line-height: 1;
|
|
68
|
+
padding: 2px 6px; border-radius: var(--radius-sm, 6px);
|
|
69
|
+
}
|
|
70
|
+
.js-file-del:hover { color: var(--fail, #dc2626); background: var(--fail-soft, #fef2f2); }
|
|
71
|
+
|
|
72
|
+
/* add a file (rail head) */
|
|
73
|
+
.js-addfile {
|
|
74
|
+
inline-size: 100%; margin-block-end: 4px; font-size: 0.8rem;
|
|
75
|
+
border: 1px dashed var(--border, #e2e8f0); border-radius: var(--radius-sm, 6px);
|
|
76
|
+
background: transparent; color: var(--muted, #64748b); padding: var(--space-1, 0.25rem);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* the template gallery (pen bar) */
|
|
80
|
+
.js-gallery { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
|
81
|
+
.js-template {
|
|
82
|
+
border: 1px solid var(--border, #e2e8f0); background: var(--surface, #fff);
|
|
83
|
+
border-radius: 999px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer; color: var(--fg, #0f172a);
|
|
84
|
+
}
|
|
85
|
+
.js-template:hover { border-color: var(--accent, #2563eb); color: var(--accent, #2563eb); }
|
|
86
|
+
|
|
87
|
+
/* the editable file name (editor head) */
|
|
88
|
+
.js-editor-name {
|
|
89
|
+
border: none; background: transparent; font-family: var(--mono, monospace);
|
|
90
|
+
font-size: 0.8rem; color: var(--fg, #0f172a); padding: 2px 4px;
|
|
91
|
+
border-radius: var(--radius-sm, 6px); min-width: 0;
|
|
92
|
+
}
|
|
93
|
+
.js-editor-name:hover, .js-editor-name:focus-visible { background: var(--accent-soft, #eff6ff); outline: none; }
|
|
94
|
+
|
|
95
|
+
.js-badge {
|
|
96
|
+
flex: 0 0 auto; font-size: 0.65rem; font-family: var(--mono, monospace);
|
|
97
|
+
padding: 1px 5px; border-radius: 999px; line-height: 1.4;
|
|
98
|
+
}
|
|
99
|
+
.js-badge[data-badge="view"] { background: #e6efff; color: #1e40af; }
|
|
100
|
+
.js-badge[data-badge="query"] { background: #e0f2fe; color: #0e7490; }
|
|
101
|
+
.js-badge[data-badge="json"] { background: #f1f5f9; color: #475569; }
|
|
102
|
+
.js-badge[data-badge="model"] { background: #dcfce7; color: #15803d; }
|
|
103
|
+
.js-badge[data-badge="flow"] { background: #fef3c7; color: #b45309; }
|
|
104
|
+
.dark .js-badge[data-badge="view"] { background: #1e293b; color: #93c5fd; }
|
|
105
|
+
.dark .js-badge[data-badge="query"] { background: #0c2a33; color: #67e8f9; }
|
|
106
|
+
.dark .js-badge[data-badge="json"] { background: #1e293b; color: #cbd5e1; }
|
|
107
|
+
.dark .js-badge[data-badge="model"] { background: #0f2a1a; color: #86efac; }
|
|
108
|
+
.dark .js-badge[data-badge="flow"] { background: #2a2011; color: #fcd34d; }
|
|
109
|
+
|
|
110
|
+
/* ——— editor ——— */
|
|
111
|
+
.js-editor-head {
|
|
112
|
+
display: flex; align-items: center; gap: var(--space-2, 0.5rem);
|
|
113
|
+
border: 1px solid var(--border, #e2e8f0); border-bottom: none;
|
|
114
|
+
border-radius: var(--radius-sm, 6px) var(--radius-sm, 6px) 0 0;
|
|
115
|
+
background: var(--surface, #fff); padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
|
|
116
|
+
font-family: var(--mono, monospace); font-size: 0.75rem; color: var(--muted, #64748b);
|
|
117
|
+
}
|
|
118
|
+
.js-editor-input {
|
|
119
|
+
flex: 1; min-height: 0; resize: none;
|
|
120
|
+
border: 1px solid var(--border, #e2e8f0);
|
|
121
|
+
border-radius: 0 0 var(--radius-sm, 6px) var(--radius-sm, 6px);
|
|
122
|
+
font-family: var(--mono, monospace); font-size: 0.85rem;
|
|
123
|
+
padding: var(--space-2, 0.5rem); color: var(--fg, #0f172a); background: var(--surface, #fff);
|
|
124
|
+
}
|
|
125
|
+
/* a write landed while the buffer was dirty — the human's text is kept and
|
|
126
|
+
the incoming version stays one click away (never a silent clobber) */
|
|
127
|
+
.js-conflict {
|
|
128
|
+
display: flex; align-items: center; gap: var(--space-2, 0.5rem); flex-wrap: wrap;
|
|
129
|
+
border: 1px solid var(--warn, #b45309); border-left-width: 4px;
|
|
130
|
+
border-radius: var(--radius-sm, 6px) var(--radius-sm, 6px) 0 0;
|
|
131
|
+
background: var(--warn-soft, #fffbeb); color: var(--warn, #b45309);
|
|
132
|
+
padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem); font-size: 0.8rem;
|
|
133
|
+
}
|
|
134
|
+
.js-conflict-take {
|
|
135
|
+
border: 1px solid var(--warn, #b45309); background: transparent; cursor: pointer;
|
|
136
|
+
color: var(--warn, #b45309); border-radius: var(--radius-sm, 6px);
|
|
137
|
+
padding: 1px 8px; font-size: 0.75rem;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.js-errorstrip {
|
|
141
|
+
display: flex; flex-direction: column; gap: 2px; margin-top: var(--space-2, 0.5rem);
|
|
142
|
+
}
|
|
143
|
+
.js-errorline {
|
|
144
|
+
border: 1px solid var(--fail, #dc2626); border-left-width: 4px;
|
|
145
|
+
border-radius: var(--radius-sm, 6px); background: var(--fail-soft, #fef2f2); color: var(--fail, #dc2626);
|
|
146
|
+
font-family: var(--mono, monospace); font-size: 0.8rem;
|
|
147
|
+
padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem); text-align: left; cursor: pointer;
|
|
148
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ——— stage ——— */
|
|
152
|
+
.js-stage-head { font-family: var(--mono, monospace); font-size: 0.75rem; color: var(--muted, #64748b); padding-bottom: var(--space-2, 0.5rem); }
|
|
153
|
+
.js-stage-mount { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain;
|
|
154
|
+
border: 1px solid var(--border, #e2e8f0); border-radius: var(--radius-sm, 6px); padding: var(--space-3, 0.75rem); }
|
|
155
|
+
.js-stage-fail { border-color: var(--fail, #dc2626); color: var(--fail, #dc2626); }
|
|
156
|
+
.js-stage-result { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; }
|
|
157
|
+
|
|
158
|
+
/* ——— the splitter (a pointer-capture widget; this is the grab bar) ——— */
|
|
159
|
+
.js-split {
|
|
160
|
+
grid-area: split; align-self: stretch; justify-self: center;
|
|
161
|
+
inline-size: 10px; padding: 0; border: none; background: transparent;
|
|
162
|
+
cursor: col-resize; touch-action: none; z-index: 1;
|
|
163
|
+
}
|
|
164
|
+
.js-split::before {
|
|
165
|
+
content: ""; display: block; inline-size: 4px; block-size: 100%;
|
|
166
|
+
margin-inline: auto; background: var(--border, #e2e8f0); border-radius: 2px;
|
|
167
|
+
}
|
|
168
|
+
.js-split:hover::before, .js-split:focus-visible::before { background: var(--accent, #2563eb); }
|
|
169
|
+
.js-split:focus-visible { outline: none; }
|
|
170
|
+
/* the stacked layout splits by rows; the horizontal handle is hidden there */
|
|
171
|
+
.jstudio[data-mode="top"] .js-split { display: none; }
|
|
172
|
+
|
|
173
|
+
/* the phone pane switcher exists only below the breakpoint */
|
|
174
|
+
.js-panebar { display: none; }
|
|
175
|
+
|
|
176
|
+
/* ——— responsive: ONE pane at a time behind the segmented switcher ———
|
|
177
|
+
* A phone never stacks rail + editor + stage into a tall scroll: the
|
|
178
|
+
* grid becomes one column with a switcher row, and the two unselected
|
|
179
|
+
* panes are hidden by attribute-scoped rules. The panes stay MOUNTED,
|
|
180
|
+
* so a hidden editor keeps its caret, its scroll and its undo stack,
|
|
181
|
+
* and switching costs one attribute write on `.jstudio`.
|
|
182
|
+
*/
|
|
183
|
+
@media (max-width: 1024px) {
|
|
184
|
+
.jstudio, .jstudio[data-mode="right"], .jstudio[data-mode="top"] {
|
|
185
|
+
grid-template-columns: minmax(0, 1fr);
|
|
186
|
+
grid-template-areas: "penbar" "panes" "rail" "editor" "stage";
|
|
187
|
+
}
|
|
188
|
+
.js-panebar { display: flex; grid-area: panes; align-self: start; }
|
|
189
|
+
.js-panebar .seg-btn { flex: 1; }
|
|
190
|
+
.jstudio[data-pane="files"] .js-editor,
|
|
191
|
+
.jstudio[data-pane="files"] .js-stage,
|
|
192
|
+
.jstudio[data-pane="editor"] .js-rail,
|
|
193
|
+
.jstudio[data-pane="editor"] .js-stage,
|
|
194
|
+
.jstudio[data-pane="stage"] .js-rail,
|
|
195
|
+
.jstudio[data-pane="stage"] .js-editor { display: none; }
|
|
196
|
+
/* the drag splitter and the three-way layout switcher both divide a
|
|
197
|
+
screen showing TWO panes; neither has meaning here, and the
|
|
198
|
+
splitter's `grid-area: split` no longer exists in this template */
|
|
199
|
+
.js-split, .js-layout { display: none; }
|
|
200
|
+
/* the keyboard seam: the editor pane reserves the published inset as
|
|
201
|
+
scroll room, so the caret clears the on-screen keyboard */
|
|
202
|
+
.js-editor { padding-bottom: var(--kb-inset, 0px); }
|
|
203
|
+
/* The pen bar is a single centred row on a desktop. On a phone that row
|
|
204
|
+
has no width to give, so the template gallery wrapped into a tower of
|
|
205
|
+
chips down the middle of the bar and pushed the file count and Run out
|
|
206
|
+
to the sides — 236px of chrome above a 350px-wide editor. It becomes
|
|
207
|
+
two honest rows instead: the identity line, then the gallery as a
|
|
208
|
+
horizontal scroll strip (the suite's mobile pattern), with the
|
|
209
|
+
desktop-only spacer switched off so nothing is flung to the edges. */
|
|
210
|
+
.js-penbar { flex-wrap: wrap; }
|
|
211
|
+
.js-spacer { display: none; }
|
|
212
|
+
.js-gallery {
|
|
213
|
+
inline-size: 100%; order: 1; flex-wrap: nowrap;
|
|
214
|
+
overflow-x: auto; scrollbar-width: none;
|
|
215
|
+
mask-image: linear-gradient(90deg, #000 92%, transparent);
|
|
216
|
+
}
|
|
217
|
+
.js-template { flex: none; white-space: nowrap; }
|
|
218
|
+
/* touch targets: rows, segments and pen-bar buttons are finger-sized */
|
|
219
|
+
.js-file, .js-file-del, .js-addfile, .js-template,
|
|
220
|
+
.jstudio .seg-btn, .js-penbar .btn { min-height: 44px; }
|
|
221
|
+
}
|
|
222
|
+
@media (max-width: 760px) {
|
|
223
|
+
/* the rail was a horizontal scroll strip while it sat stacked ABOVE
|
|
224
|
+
the editor and had to stay compact. As its own full pane it is a
|
|
225
|
+
plain vertical list again — the whole screen is the file list */
|
|
226
|
+
.js-file-name { white-space: normal; overflow-wrap: anywhere; }
|
|
227
|
+
}
|