@sequenceholdings/artifact-studio 0.1.15 → 0.2.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/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { getJson, getJsonOr404, postJson } from './api.js';
6
6
  import { ENV_URLS, readLocalConfig, resolveEnvironment, writeLocalConfig, } from './config.js';
7
7
  import { writeBuildArtifact } from './build.js';
8
8
  import { buildArtifactStudioProjectIsolated } from './build-subprocess.js';
9
+ import { resolveArtifactStudioPeerVersions, } from './peer-versions.js';
9
10
  import { prepareArtifactBuildRoot } from './prepare-build.js';
10
11
  import { parseArtifactStudioManifest } from './manifest.js';
11
12
  import { hasArtifactStudioManifest, readArtifactStudioSource } from './project.js';
@@ -14,6 +15,7 @@ import { acquireDevLock } from './dev-lock.js';
14
15
  import { runWatchLoop } from './watch-loop.js';
15
16
  import { hashLinkedSources, parseLinks, rebuildLinkedDep, resolveLinkedDeps, syncLinkedDist, } from './dev-link.js';
16
17
  import { lintArtifactSandbox, reportSandboxWarnings } from './sandbox-lint.js';
18
+ import { assertActiveDeployProvenance } from './active-deploy-policy.js';
17
19
  import yaml from 'js-yaml';
18
20
  const COMMANDS = [
19
21
  'init', 'whoami', 'link', 'env', 'status', 'list', 'show',
@@ -41,6 +43,11 @@ const USAGE = `usage:
41
43
  repo's default branch). The target project comes from the source's
42
44
  artifact.bundle.yml project_id.
43
45
 
46
+ Official (active) deploys require a clean \`main\` checkout with a resolved
47
+ git commit — merge first, then \`deploy --repo …\` (or a clean local \`main\`
48
+ checkout). Feature branches and dirty trees are preview-only
49
+ (\`artifact dev\` / preview channel).
50
+
44
51
  Interactive --repo builds clone over smart-HTTP and require a repo:read git
45
52
  PAT in ATLAS_GIT_PAT — create one with \`seq-studio auth pat create --scopes
46
53
  repo:read\` or in Atlas → Settings → Tokens. Headless M2M builds use JSON
@@ -302,25 +309,31 @@ async function showCommand(args) {
302
309
  const deployment = project.activeDeployment;
303
310
  if (deployment) {
304
311
  console.log(` active deployment:`);
305
- console.log(` version: ${deployment.version}`);
306
- console.log(` sourceHash: ${deployment.sourceHash}`);
307
- if (deployment.gitCommit) {
308
- const dirty = deployment.gitDirty ? ' (dirty)' : '';
309
- const branch = deployment.gitBranch ? ` on ${deployment.gitBranch}` : '';
310
- console.log(` git: ${deployment.gitCommit.slice(0, 12)}${branch}${dirty}`);
311
- }
312
- if (deployment.deployMessage)
313
- console.log(` message: ${deployment.deployMessage}`);
314
- if (deployment.createdAt)
315
- console.log(` deployedAt: ${deployment.createdAt}`);
316
- if (deployment.createdBy)
317
- console.log(` deployedBy: ${deployment.createdBy}`);
312
+ logActiveDeploymentDetails(deployment);
318
313
  }
319
314
  else {
320
315
  console.log(` active deployment: none`);
321
316
  }
322
317
  return 0;
323
318
  }
319
+ function logActiveDeploymentDetails(deployment) {
320
+ console.log(` version: ${deployment.version}`);
321
+ console.log(` sourceHash: ${deployment.sourceHash}`);
322
+ if (deployment.gitCommit) {
323
+ const dirty = deployment.gitDirty ? ' (dirty)' : '';
324
+ const branch = deployment.gitBranch ? ` on ${deployment.gitBranch}` : '';
325
+ console.log(` git: ${deployment.gitCommit.slice(0, 12)}${branch}${dirty}`);
326
+ }
327
+ if (deployment.cliVersion || deployment.atlasUiVersion) {
328
+ console.log(` peers: artifact-studio@${deployment.cliVersion ?? '?'} atlas-ui@${deployment.atlasUiVersion ?? '?'}`);
329
+ }
330
+ if (deployment.deployMessage)
331
+ console.log(` message: ${deployment.deployMessage}`);
332
+ if (deployment.createdAt)
333
+ console.log(` deployedAt: ${deployment.createdAt}`);
334
+ if (deployment.createdBy)
335
+ console.log(` deployedBy: ${deployment.createdBy}`);
336
+ }
324
337
  async function validateCommand(args) {
325
338
  const dir = resolve(args.positional[0] ?? '.');
326
339
  const source = await readArtifactStudioSource(dir);
@@ -359,7 +372,8 @@ async function buildCommand(args) {
359
372
  await writeBuildArtifact(result, out);
360
373
  console.log(`[seq-studio] built ${result.manifest.bundle.name}`);
361
374
  console.log(`[seq-studio] source: ${result.sourceHash}`);
362
- console.log(`[seq-studio] bundle: ${result.bundleHash}`);
375
+ console.log(`[seq-studio] bundle: ${result.bundleHash} (${result.bundleFormat}, ${result.chunks.length} chunks)`);
376
+ console.log(`[seq-studio] peers: artifact-studio@${result.peerVersions.cliVersion} atlas-ui@${result.peerVersions.atlasUiVersion}`);
363
377
  console.log(`[seq-studio] wrote ${out}`);
364
378
  reportSandboxWarnings(lintArtifactSandbox(result.files));
365
379
  return 0;
@@ -388,12 +402,29 @@ async function planCommand(args) {
388
402
  return 0;
389
403
  }
390
404
  const remote = await fetchRemoteProject({ env: env.url, token, slug: result.manifest.artifact.project_id });
391
- if (!remote)
405
+ if (!remote) {
392
406
  console.log(' action: create project and deploy');
393
- else if (remote.activeDeployment?.sourceHash === result.sourceHash)
407
+ return 0;
408
+ }
409
+ // Mirror the deploy --skip-unchanged decision exactly: identical source
410
+ // with a different (or unrecorded) CLI/atlas-ui pin still redeploys, so
411
+ // plan must not report no-op there.
412
+ const active = remote.activeDeployment;
413
+ if (activeDeploymentMatchesSkipPin({
414
+ active,
415
+ sourceHash: result.sourceHash,
416
+ peerVersions: result.peerVersions,
417
+ })) {
394
418
  console.log(' action: no-op');
395
- else
396
- console.log(` action: deploy over ${remote.activeDeployment?.version ?? 'no active deployment'}`);
419
+ }
420
+ else if (active && active.sourceHash === result.sourceHash) {
421
+ console.log(` action: deploy over ${active.version} ` +
422
+ `(source unchanged; pin moves ${active.cliVersion ?? 'unrecorded'}/${active.atlasUiVersion ?? 'unrecorded'} ` +
423
+ `→ ${result.peerVersions.cliVersion}/${result.peerVersions.atlasUiVersion})`);
424
+ }
425
+ else {
426
+ console.log(` action: deploy over ${active?.version ?? 'no active deployment'}`);
427
+ }
397
428
  return 0;
398
429
  }
399
430
  finally {
@@ -412,20 +443,31 @@ async function deployCommand(args) {
412
443
  token,
413
444
  });
414
445
  try {
446
+ // Fail closed before build / project create — active deploys must already
447
+ // look like a clean `main` checkout. Preview (`dev`) never hits this path.
448
+ assertActiveDeployProvenance(source.provenance);
415
449
  // --skip-unchanged: hash the raw source (no build needed) and no-op when
416
- // the remote active deployment already matches. Lets CI redeploy every
417
- // artifact on every release without paying a vite build + upload for the
418
- // unchanged ones. sourceHash covers manifest + all source files, so any
419
- // real change falls through to a full deploy.
450
+ // the remote active deployment already matches *and* was built with the
451
+ // same CLI/atlas-ui pin. Lets CI redeploy every artifact on every release
452
+ // without paying a vite build + upload for the unchanged ones. sourceHash
453
+ // covers manifest + all source files; peer versions cover the force-alias
454
+ // inputs (DES-254) so a newer CLI still rebuilds identical source.
420
455
  if (args.flags['skip-unchanged'] === true) {
421
456
  const unbuilt = await readArtifactStudioSource(source.dir);
422
457
  const slug = unbuilt.manifest.artifact.project_id;
423
458
  const remote = explicitProjectId
424
459
  ? (await getJsonOr404({ baseUrl: env.url, token, path: `/api/artifact-studio/projects/${explicitProjectId}` }))?.project ?? null
425
460
  : await fetchRemoteProject({ env: env.url, token, slug });
426
- if (remote?.activeDeployment?.sourceHash === unbuilt.sourceHash) {
461
+ const peerVersions = resolveArtifactStudioPeerVersions();
462
+ const active = remote?.activeDeployment;
463
+ if (activeDeploymentMatchesSkipPin({
464
+ active,
465
+ sourceHash: unbuilt.sourceHash,
466
+ peerVersions,
467
+ }) && active) {
427
468
  console.log(`[seq-studio] ${slug} unchanged on ${env.name} ` +
428
- `(active ${remote.activeDeployment.version} matches source ${unbuilt.sourceHash.slice(0, 12)}) skipping deploy`);
469
+ `(active ${active.version} matches source ${unbuilt.sourceHash.slice(0, 12)} ` +
470
+ `+ peers artifact-studio@${peerVersions.cliVersion} atlas-ui@${peerVersions.atlasUiVersion}) — skipping deploy`);
429
471
  return 0;
430
472
  }
431
473
  }
@@ -692,6 +734,21 @@ async function rollbackCommand(args) {
692
734
  console.log(`[seq-studio] rolled back to ${deploymentId}`);
693
735
  return 0;
694
736
  }
737
+ /**
738
+ * Whether `--skip-unchanged` may no-op: same source *and* same CLI/atlas-ui
739
+ * pin. Missing peer fields on older rows force a rebuild so the pin becomes
740
+ * observable (DES-254).
741
+ */
742
+ export function activeDeploymentMatchesSkipPin({ active, sourceHash, peerVersions, }) {
743
+ if (!active)
744
+ return false;
745
+ if (active.sourceHash !== sourceHash)
746
+ return false;
747
+ if (!active.cliVersion || !active.atlasUiVersion)
748
+ return false;
749
+ return (active.cliVersion === peerVersions.cliVersion &&
750
+ active.atlasUiVersion === peerVersions.atlasUiVersion);
751
+ }
695
752
  /**
696
753
  * Deploys never change a live project's visibility — the Studio sharing UI /
697
754
  * PATCH own that. The manifest's targets.<env>.visibility is honored at
@@ -773,11 +830,15 @@ async function uploadDeployment({ env, token, projectId, result, channel, previe
773
830
  manifest: result.manifest,
774
831
  files: result.files,
775
832
  bundle: result.bundle,
833
+ chunks: result.chunks.map((chunk) => ({ fileName: chunk.fileName, content: chunk.code })),
834
+ bundleFormat: result.bundleFormat,
776
835
  sourceMap: result.sourceMap,
777
836
  ...(version ? { version } : {}),
778
837
  channel,
779
838
  previewKey,
780
839
  deployMessage: message ?? null,
840
+ cliVersion: result.peerVersions.cliVersion,
841
+ atlasUiVersion: result.peerVersions.atlasUiVersion,
781
842
  ...provenance,
782
843
  },
783
844
  });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Versions of the packages that actually land in an artifact bundle under the
3
+ * CLI atlas-ui stamp (DES-254). See `VERSION-PIN.md`.
4
+ *
5
+ * - `cliVersion` — `@sequenceholdings/artifact-studio` (the build package that
6
+ * owns `PEER_RESOLVE_ALIAS`).
7
+ * - `atlasUiVersion` — the `@sequenceholdings/atlas-ui` copy resolved from
8
+ * *that* package's dependency tree (not the artifact's package.json).
9
+ */
10
+ export interface ArtifactStudioPeerVersions {
11
+ cliVersion: string;
12
+ atlasUiVersion: string;
13
+ }
14
+ /**
15
+ * Resolve the building CLI + force-aliased atlas-ui versions from disk.
16
+ * Throws if either package.json is unreadable — a silent null here would
17
+ * undermine the observability DES-254 adds for the blast-radius scanner.
18
+ */
19
+ export declare function resolveArtifactStudioPeerVersions(): ArtifactStudioPeerVersions;
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const cliRequire = createRequire(import.meta.url);
6
+ const thisModuleDir = dirname(fileURLToPath(import.meta.url));
7
+ function readPackageVersion({ packageJsonPath, expectedName }) {
8
+ const raw = readFileSync(packageJsonPath, 'utf8');
9
+ let parsed;
10
+ try {
11
+ parsed = JSON.parse(raw);
12
+ }
13
+ catch (error) {
14
+ throw new Error(`Failed to parse ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
15
+ }
16
+ if (typeof parsed.name === 'string' && parsed.name !== expectedName) {
17
+ throw new Error(`Expected package name ${expectedName} at ${packageJsonPath}, got ${parsed.name}`);
18
+ }
19
+ if (typeof parsed.version !== 'string' || parsed.version.trim() === '') {
20
+ throw new Error(`Missing version in ${packageJsonPath}`);
21
+ }
22
+ return parsed.version.trim();
23
+ }
24
+ /**
25
+ * Resolve the building CLI + force-aliased atlas-ui versions from disk.
26
+ * Throws if either package.json is unreadable — a silent null here would
27
+ * undermine the observability DES-254 adds for the blast-radius scanner.
28
+ */
29
+ export function resolveArtifactStudioPeerVersions() {
30
+ // Built code lives in `dist/`; package.json sits one level up. The same
31
+ // relative path works when vitest loads from `src/`.
32
+ const cliPackageJson = resolve(thisModuleDir, '..', 'package.json');
33
+ const atlasUiPackageJson = cliRequire.resolve('@sequenceholdings/atlas-ui/package.json');
34
+ return {
35
+ cliVersion: readPackageVersion({
36
+ packageJsonPath: cliPackageJson,
37
+ expectedName: '@sequenceholdings/artifact-studio',
38
+ }),
39
+ atlasUiVersion: readPackageVersion({
40
+ packageJsonPath: atlasUiPackageJson,
41
+ expectedName: '@sequenceholdings/atlas-ui',
42
+ }),
43
+ };
44
+ }
package/dist/project.d.ts CHANGED
@@ -9,6 +9,9 @@ export interface ArtifactStudioSource {
9
9
  manifest: ArtifactStudioManifest;
10
10
  files: ArtifactStudioSourceFile[];
11
11
  sourceHash: string;
12
+ /** Path prefixes from `.artifactignore` — also enforced as non-importable by Vite. */
13
+ ignorePatterns: string[];
12
14
  }
13
15
  export declare function hasArtifactStudioManifest(rootDir: string): boolean;
14
16
  export declare function readArtifactStudioSource(rootDir: string): Promise<ArtifactStudioSource>;
17
+ export declare function loadArtifactIgnorePatterns(root: string): Promise<string[]>;
package/dist/project.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, readdir } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, relative, resolve } from 'node:path';
4
4
  import yaml from 'js-yaml';
5
+ import { ARTIFACT_IGNORE_FILE, isArtifactIgnoredPath, parseArtifactIgnore, } from './artifact-ignore.js';
5
6
  import { computeSourceHash } from './hash.js';
6
7
  import { parseArtifactStudioManifest } from './manifest.js';
7
8
  import { normalizeArtifactPath } from './paths.js';
@@ -16,7 +17,8 @@ export async function readArtifactStudioSource(rootDir) {
16
17
  const root = resolve(rootDir);
17
18
  const rawManifest = yaml.load(await readFile(join(root, 'artifact.bundle.yml'), 'utf8'));
18
19
  const manifest = parseArtifactStudioManifest(rawManifest);
19
- const files = await collectSourceFiles(root);
20
+ const ignorePatterns = await loadArtifactIgnorePatterns(root);
21
+ const files = await collectSourceFiles(root, ignorePatterns);
20
22
  if (!files.some((file) => file.path === manifest.artifact.entrypoint)) {
21
23
  throw new Error(`Entrypoint file not found: ${manifest.artifact.entrypoint}`);
22
24
  }
@@ -26,22 +28,28 @@ export async function readArtifactStudioSource(rootDir) {
26
28
  files,
27
29
  // Hash the RAW manifest, not the parsed one — see computeSourceHash.
28
30
  sourceHash: computeSourceHash({ files, manifest: rawManifest }),
31
+ ignorePatterns,
29
32
  };
30
33
  }
31
- async function collectSourceFiles(root) {
34
+ async function collectSourceFiles(root, ignorePatterns) {
32
35
  const files = [];
33
36
  async function walk(dir) {
34
37
  const entries = await readdir(dir, { withFileTypes: true });
35
38
  for (const entry of entries) {
39
+ // Built-in ignores first — `normalizeArtifactPath` rejects `node_modules`
40
+ // (and similar) as source paths, so we must not normalize those names.
41
+ if (entry.isDirectory() && IGNORED_DIRS.has(entry.name))
42
+ continue;
36
43
  const absolute = join(dir, entry.name);
44
+ const path = normalizeArtifactPath(relative(root, absolute));
45
+ if (isArtifactIgnoredPath(path, ignorePatterns))
46
+ continue;
37
47
  if (entry.isDirectory()) {
38
- if (!IGNORED_DIRS.has(entry.name))
39
- await walk(absolute);
48
+ await walk(absolute);
40
49
  continue;
41
50
  }
42
51
  if (!entry.isFile())
43
52
  continue;
44
- const path = normalizeArtifactPath(relative(root, absolute));
45
53
  if (!isTextFile(path))
46
54
  continue;
47
55
  files.push({
@@ -54,9 +62,17 @@ async function collectSourceFiles(root) {
54
62
  await walk(root);
55
63
  return files.sort((a, b) => a.path.localeCompare(b.path));
56
64
  }
65
+ export async function loadArtifactIgnorePatterns(root) {
66
+ const ignorePath = join(root, ARTIFACT_IGNORE_FILE);
67
+ if (!existsSync(ignorePath))
68
+ return [];
69
+ return parseArtifactIgnore(await readFile(ignorePath, 'utf8'));
70
+ }
57
71
  function isTextFile(path) {
58
72
  const ext = path.includes('.') ? path.slice(path.lastIndexOf('.')) : '';
59
- return path === 'artifact.bundle.yml' || TEXT_EXTENSIONS.has(ext);
73
+ return (path === 'artifact.bundle.yml' ||
74
+ path === ARTIFACT_IGNORE_FILE ||
75
+ TEXT_EXTENSIONS.has(ext));
60
76
  }
61
77
  function inferContentType(path) {
62
78
  if (path.endsWith('.tsx') || path.endsWith('.ts'))
@@ -0,0 +1,4 @@
1
+ /** Vite async-chunk namespace. Keep in sync with atlas `limits.ts` (ARTIFACT_RUNTIME_CHUNK_DIR). */
2
+ export declare const ARTIFACT_RUNTIME_CHUNK_DIR = "chunks";
3
+ export declare function isArtifactRuntimeChunkPath(path: string): boolean;
4
+ export declare function assertSafeRuntimeChunkFileName(fileName: string): string;
@@ -0,0 +1,15 @@
1
+ /** Vite async-chunk namespace. Keep in sync with atlas `limits.ts` (ARTIFACT_RUNTIME_CHUNK_DIR). */
2
+ export const ARTIFACT_RUNTIME_CHUNK_DIR = 'chunks';
3
+ export function isArtifactRuntimeChunkPath(path) {
4
+ return path === ARTIFACT_RUNTIME_CHUNK_DIR || path.startsWith(`${ARTIFACT_RUNTIME_CHUNK_DIR}/`);
5
+ }
6
+ export function assertSafeRuntimeChunkFileName(fileName) {
7
+ const normalized = fileName.replace(/\\/g, '/').replace(/^\.\//, '');
8
+ if (!isArtifactRuntimeChunkPath(normalized)) {
9
+ throw new Error(`Runtime chunk path must be under ${ARTIFACT_RUNTIME_CHUNK_DIR}/: ${fileName}`);
10
+ }
11
+ if (normalized.includes('..') || normalized.startsWith('/')) {
12
+ throw new Error(`Invalid runtime chunk path: ${fileName}`);
13
+ }
14
+ return normalized;
15
+ }
package/dist/sdk.d.ts CHANGED
@@ -10,6 +10,12 @@ export interface SequenceApiFetchResponse<TBody = unknown> {
10
10
  }
11
11
  export interface SequenceApiUploadOptions {
12
12
  timeoutMs?: number;
13
+ /**
14
+ * HTTP method for the multipart request. Defaults to POST; PUT is allowed
15
+ * for update-with-file routes (e.g. replacing an existing disclosure's
16
+ * file). Both are gated by the same `capabilities.api.write` check.
17
+ */
18
+ method?: 'POST' | 'PUT';
13
19
  }
14
20
  export interface SequenceApi {
15
21
  fetch<TBody = unknown>(path: string, options?: SequenceApiFetchOptions): Promise<SequenceApiFetchResponse<TBody>>;
@@ -90,6 +96,17 @@ export interface SequenceHost {
90
96
  * user to an external origin.
91
97
  */
92
98
  navigate(path: string): void;
99
+ /**
100
+ * Coordinate a viewport-modal scrim with the parent Atlas shell. The
101
+ * artifact keeps ownership of its dialog and in-frame scrim; the host only
102
+ * dims shell chrome outside the iframe. Calls are reference-counted by
103
+ * `overlayId`, so every open transition must eventually send `open: false`.
104
+ */
105
+ setOverlayState(state: {
106
+ overlayId: string;
107
+ scope: 'viewport';
108
+ open: boolean;
109
+ }): void;
93
110
  }
94
111
  export interface SequenceArtifactSdk {
95
112
  api: SequenceApi;
@@ -153,14 +153,38 @@ export function localGitMetadata(dir) {
153
153
  };
154
154
  try {
155
155
  const gitCommit = String(execFileSync('git', ['rev-parse', 'HEAD'], gitOpts)).trim();
156
- const gitBranch = String(execFileSync('git', ['branch', '--show-current'], gitOpts)).trim();
156
+ let gitBranch = String(execFileSync('git', ['branch', '--show-current'], gitOpts)).trim();
157
+ // GitHub Actions (and similar) check out a SHA → detached HEAD even when
158
+ // deploying main. Map that back to `main` only when HEAD equals the tip of
159
+ // main / origin/main — do not trust GITHUB_REF alone (workflow branch can
160
+ // differ from the checked-out SHA).
161
+ if (!gitBranch) {
162
+ gitBranch = resolveDetachedMainBranch({ gitOpts, gitCommit }) ?? '';
163
+ }
157
164
  const gitDirty = String(execFileSync('git', ['status', '--porcelain'], gitOpts)).trim().length > 0;
158
- return { gitCommit, gitBranch, gitDirty };
165
+ return { gitCommit, gitBranch: gitBranch || null, gitDirty };
159
166
  }
160
167
  catch {
161
168
  return { gitCommit: null, gitBranch: null, gitDirty: null };
162
169
  }
163
170
  }
171
+ /**
172
+ * When `git branch --show-current` is empty (detached HEAD), return `main` if
173
+ * HEAD is exactly the tip of a local/remote main ref — otherwise null.
174
+ */
175
+ function resolveDetachedMainBranch({ gitOpts, gitCommit, }) {
176
+ for (const ref of ['refs/heads/main', 'main', 'refs/remotes/origin/main', 'origin/main']) {
177
+ try {
178
+ const tip = String(execFileSync('git', ['rev-parse', ref], gitOpts)).trim();
179
+ if (tip === gitCommit)
180
+ return 'main';
181
+ }
182
+ catch {
183
+ // ref missing in this clone (shallow / no remotes) — try the next one
184
+ }
185
+ }
186
+ return null;
187
+ }
164
188
  async function makeTempDir() {
165
189
  const dir = await mkdtemp(join(tmpdir(), 'artifact-src-'));
166
190
  // Canonicalize: the Vite html plugin emits the entry file name relative to
@@ -0,0 +1,90 @@
1
+ # Sequence Artifact Studio app
2
+
3
+ This is a sandboxed iframe SPA that runs inside Atlas and talks to Atlas backend routes through a postMessage bridge exposed as `seq.api.*` from `@sequenceholdings/artifact-studio`. No direct network access, no API keys in the bundle — everything goes through the bridge, and every endpoint the artifact uses must be declared in `artifact.bundle.yml`.
4
+
5
+ ## Three rules that bite
6
+
7
+ 1. **Envelope unwrap.** Atlas wraps every CRM-style response in `{ success: true, data: T }`. The bridge passes that envelope through raw — it does **not** unwrap automatically. Route every read/write through `unwrap<T>()` in `src/lib/api.ts`:
8
+ ```ts
9
+ import { unwrap, seq } from './lib/api'
10
+ const customer = await unwrap<Customer>(seq.api.get(`/api/crm/customers/${id}`))
11
+ ```
12
+ For endpoints with extra envelope fields (pagination `meta`, warnings), drop to `seq.api.fetch` and read `res.body` directly.
13
+
14
+ 2. **FormData blocker.** `seq.api.fetch` `JSON.stringify`s any non-string `body` value, which silently turns `FormData` into `"{}"`. For multipart uploads, use `seq.api.upload(path, file, fields?, options?)`; the bridge constructs `FormData` in the trusted host and returns the usual `{ ok, status, body }` envelope.
15
+
16
+ 3. **Capability matcher: exact paths, `/*` prefix wildcards, and single-segment `*` patterns.** `{id}` template forms do **not** match. List both the bare path and the prefix wildcard for broad resources, or use a least-privilege segment pattern for dynamic routes:
17
+ ```yaml
18
+ - /api/crm/customers
19
+ - /api/crm/customers/*
20
+ - /api/lattice/runs/*/advance
21
+ ```
22
+
23
+ ## Prerequisite
24
+
25
+ None — `seq-studio artifact init <dir>` works from any directory; no monorepo checkout is required. The template lists `@sequenceholdings/artifact-studio` and `@sequenceholdings/atlas-ui` in **devDependencies** for local editor/type-check tooling only. `seq-studio artifact build|deploy` bundles against the CLI's own pinned copies of `react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, and `@sequenceholdings/atlas-ui`, and resolves `@sequenceholdings/artifact-studio` to a virtual `window.seq` module. Remote deploys (`--repo` / `--git-url`) run `pnpm install --prod` for **app-specific** `dependencies` only (e.g. `recharts`, `reactflow`) from the artifact lockfile.
26
+
27
+ ## Package manager
28
+
29
+ Use **pnpm** for artifacts you will deploy from git — `seq-studio artifact build|deploy --repo/--git-url` runs `pnpm install --frozen-lockfile --prod` when `package.json` is present, so commit `pnpm-lock.yaml`. Local editor-only installs can use another package manager, but pnpm keeps Sequence's `minimumReleaseAge` supply-chain quarantine in effect (see the studio-cli README). If you have a stale checkout from the old `file:`-link era, `rm -rf node_modules` and reinstall.
30
+
31
+ ## Key files
32
+
33
+ | File | Role |
34
+ |---|---|
35
+ | `artifact.bundle.yml` | Capability manifest — every API path the artifact calls: exact paths, `/*` prefix wildcards, or single-segment `*` patterns |
36
+ | `src/main.tsx` | Providers: `QueryClientProvider` → `PortalContainerProvider` → `App` |
37
+ | `src/App.tsx` | Entrypoint UI |
38
+ | `src/lib/api.ts` | `unwrap<T>` envelope helper + re-export of `seq` |
39
+ | `src/styles.css` | Tailwind v4 + `@sequenceholdings/atlas-ui/tokens.css` (+ v2 sheets; build injects them when missing) |
40
+ | `vite.config.ts` | Vite + React + Tailwind v4 plugin |
41
+
42
+ ## Defaults
43
+
44
+ - UI: prefer `@sequenceholdings/atlas-ui` primitives (`Button`, `Card`, `Input`, `Dialog`, `Select`, `Checkbox`, `DropdownMenu`, `KpiTile`, `Switch`, …) over raw Tailwind. The design-system cutover made `Select`, `Checkbox`, `DropdownMenu`, `Command`, `Calendar`, `ScrollArea`, `Table`, and the Task family first-class exports — use them directly; fall back to `Popover` compositions or native controls only when a component is genuinely missing.
45
+ - Routing: `HashRouter`. Mount routes at `/`, not at the Atlas app prefix.
46
+ - Data: React Query is wired in `src/main.tsx`. Use `useQuery` / `useMutation` against `unwrap(seq.api.*)`.
47
+ - Icons: do **not** add third-party deps like `@tabler/icons-react`. Deploys from git (`--repo` / `--git-url`) build a bare tree — `seq-studio` runs `pnpm install --prod` from your lockfile, then aliases platform peers (`react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, `@sequenceholdings/atlas-ui`). List only app-specific imports (e.g. `recharts`) in `dependencies`; do not add file/workspace `@sequenceholdings/*` specs in git-service repos, because remote installs only accept registry semver specs. Inline SVG components in `src/icons.tsx`.
48
+
49
+ ## Tokens — colour comes from semantic roles, never a raw ramp
50
+
51
+ This app bundles `@sequenceholdings/atlas-ui/tokens.css` and re-themes per tenant for free. Reach for a **semantic role** and colour follows the host brand and light/dark automatically.
52
+
53
+ **Never** use the numbered `black-*` / `white-*` opacity scales (`text-black-40`, `bg-black-6`, `border-black-10`), raw Tailwind neutrals (`gray|slate|zinc|neutral|stone-N`), arbitrary hex in class strings (`bg-[#1f2937]`), or `var(--black-*)` / `var(--color-black-*)`. These are **retired** — post-cutover they resolve to nothing or to a fixed ink that won't flip in dark. Bare `text-white` / `bg-black` and opacity modifiers like `bg-foreground/10` stay legal; only the numbered steps and raw palettes are banned. There is no old→new lookup table on purpose — pick by *what the element is and what it sits on*.
54
+
55
+ | Situation | Role |
56
+ |---|---|
57
+ | The page surface | `bg-background` — the page only, never a panel |
58
+ | Bordered/rounded panel, card, list | `bg-card` — a border is not a surface; every bordered container needs an explicit surface |
59
+ | Inset well inside a card (code block, read-only value) | `bg-muted` |
60
+ | Form field (input, textarea, select trigger) | `bg-field` |
61
+ | Interactive / hover fill | `bg-accent` / `bg-surface-hover` |
62
+ | Selected / active / focus state fill | `bg-foreground/8`–`bg-foreground/15` overlay |
63
+ | Modal scrim behind a dialog | `bg-overlay` — never `bg-foreground/N` (washes lighter in dark) |
64
+ | Primary text | `text-foreground` |
65
+ | Resting nav items, meta rows | `text-foreground-secondary` |
66
+ | Section labels, captions | `text-muted-foreground` |
67
+ | Standard border | `border-border` (form controls: `border-input`) |
68
+
69
+ Surfaces **nest** — never repeat a rung (`bg-card` inside `bg-card` is invisible): `bg-background` (page) → `bg-card` (panel, + border + `shadow-xs` to lift) → `bg-muted` (inset well).
70
+
71
+ **Dual-runtime.** Inside Atlas the artifact runs in its own iframe and inherits nothing — the runtime posts the host's resolved brand in as inline custom properties (theme channel + `data-tokens`). Standalone `vite dev` has no host, so the artifact must still render on the base semantic roles. **Do not** bundle `tokens.v2.tenants.css`, hardcode an opco's brand, or add `[data-theme]` selectors — that pins the artifact to one tenant.
72
+
73
+ ## Bridge methods
74
+
75
+ Two primitives on `seq.api`:
76
+
77
+ - `seq.api.fetch(path, options?)` returns `{ ok, status, body }` — does not throw on non-2xx. Use when you need the status (e.g. handle 409) or the raw envelope with extra fields like `meta`.
78
+ - `seq.api.request(method, path, body?)` throws on non-2xx and returns parsed JSON. Sugar wrappers `seq.api.get / .post / .patch / .delete` are thin shells around `request`, so they also throw.
79
+
80
+ Wrap reads/writes that should auto-throw through `unwrap()` in `src/lib/api.ts`. Drop to `seq.api.fetch` when you need pagination `meta` or status inspection.
81
+
82
+ ## Lattice embed context
83
+
84
+ When the artifact is served from a Lattice human node (the **Tasks/Inbox** task surface, or the builder Artifacts tab), `seq.lattice.context()` tells you which run/node/step you're rendering for:
85
+
86
+ ```ts
87
+ const { runId, nodeId, stepId, processId } = seq.lattice.context()
88
+ ```
89
+
90
+ It's **synchronous** (the host injects this into the sandbox up front — no bridge round trip) and each field is `null` when unavailable. The Inbox provides all four; the builder Artifacts tab has no live `stepId`; a standalone open has no context at all. Use it instead of hand-parsing `window.location` — that's the sanctioned, stable accessor, and you don't need to declare anything in `artifact.bundle.yml` for it. To read step-scoped data, pass `runId`/`stepId` to the relevant Atlas endpoint (which still authorizes the caller server-side).
@@ -1,66 +1,7 @@
1
1
  # Sequence Artifact Studio app
2
2
 
3
- This is a sandboxed iframe SPA that runs inside Atlas and talks to Atlas backend routes through a postMessage bridge exposed as `seq.api.*` from `@sequenceholdings/artifact-studio`. No direct network access, no API keys in the bundle everything goes through the bridge, and every endpoint the artifact uses must be declared in `artifact.bundle.yml`.
3
+ Agent instructions for this artifact live in `AGENTS.md`one file every coding agent reads
4
+ (Cursor and Codex load `AGENTS.md` natively; the import below pulls it in for Claude Code).
5
+ Add or edit guidance in `AGENTS.md`, not here.
4
6
 
5
- ## Three rules that bite
6
-
7
- 1. **Envelope unwrap.** Atlas wraps every CRM-style response in `{ success: true, data: T }`. The bridge passes that envelope through raw — it does **not** unwrap automatically. Route every read/write through `unwrap<T>()` in `src/lib/api.ts`:
8
- ```ts
9
- import { unwrap, seq } from './lib/api'
10
- const customer = await unwrap<Customer>(seq.api.get(`/api/crm/customers/${id}`))
11
- ```
12
- For endpoints with extra envelope fields (pagination `meta`, warnings), drop to `seq.api.fetch` and read `res.body` directly.
13
-
14
- 2. **FormData blocker.** `seq.api.fetch` `JSON.stringify`s any non-string `body` value, which silently turns `FormData` into `"{}"`. For uploads, base64-encode the bytes and POST as JSON, or patch the Atlas bridge first.
15
-
16
- 3. **Capability matcher: exact paths, `/*` prefix wildcards, and single-segment `*` patterns.** `{id}` template forms do **not** match. List both the bare path and the prefix wildcard for broad resources, or use a least-privilege segment pattern for dynamic routes:
17
- ```yaml
18
- - /api/crm/customers
19
- - /api/crm/customers/*
20
- - /api/lattice/runs/*/advance
21
- ```
22
-
23
- ## Prerequisite
24
-
25
- None — `seq-studio artifact init <dir>` works from any directory; no monorepo checkout is required. The template lists `@sequenceholdings/artifact-studio` and `@sequenceholdings/atlas-ui` in **devDependencies** for local editor/type-check tooling only. `seq-studio artifact build|deploy` bundles against the CLI's own pinned copies of `react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, and `@sequenceholdings/atlas-ui`, and resolves `@sequenceholdings/artifact-studio` to a virtual `window.seq` module. Remote deploys (`--repo` / `--git-url`) run `pnpm install --prod` for **app-specific** `dependencies` only (e.g. `recharts`, `reactflow`) from the artifact lockfile.
26
-
27
- ## Package manager
28
-
29
- Use **pnpm** for artifacts you will deploy from git — `seq-studio artifact build|deploy --repo/--git-url` runs `pnpm install --frozen-lockfile --prod` when `package.json` is present, so commit `pnpm-lock.yaml`. Local editor-only installs can use another package manager, but pnpm keeps Sequence's `minimumReleaseAge` supply-chain quarantine in effect (see the studio-cli README). If you have a stale checkout from the old `file:`-link era, `rm -rf node_modules` and reinstall.
30
-
31
- ## Key files
32
-
33
- | File | Role |
34
- |---|---|
35
- | `artifact.bundle.yml` | Capability manifest — every API path the artifact calls: exact paths, `/*` prefix wildcards, or single-segment `*` patterns |
36
- | `src/main.tsx` | Providers: `QueryClientProvider` → `PortalContainerProvider` → `App` |
37
- | `src/App.tsx` | Entrypoint UI |
38
- | `src/lib/api.ts` | `unwrap<T>` envelope helper + re-export of `seq` |
39
- | `src/styles.css` | Tailwind v4 + `@sequenceholdings/atlas-ui/tokens.css` |
40
- | `vite.config.ts` | Vite + React + Tailwind v4 plugin |
41
-
42
- ## Defaults
43
-
44
- - UI: prefer `@sequenceholdings/atlas-ui` primitives (`Button`, `Card`, `Input`, `Dialog`, `Select`, `Checkbox`, `DropdownMenu`, `KpiTile`, `Switch`, …) over raw Tailwind. The design-system cutover made `Select`, `Checkbox`, `DropdownMenu`, `Command`, `Calendar`, `ScrollArea`, `Table`, and the Task family first-class exports — use them directly; fall back to `Popover` compositions or native controls only when a component is genuinely missing.
45
- - Routing: `HashRouter`. Mount routes at `/`, not at the Atlas app prefix.
46
- - Data: React Query is wired in `src/main.tsx`. Use `useQuery` / `useMutation` against `unwrap(seq.api.*)`.
47
- - Icons: do **not** add third-party deps like `@tabler/icons-react`. Deploys from git (`--repo` / `--git-url`) build a bare tree — `seq-studio` runs `pnpm install --prod` from your lockfile, then aliases platform peers (`react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, `@sequenceholdings/atlas-ui`). List only app-specific imports (e.g. `recharts`) in `dependencies`; do not add file/workspace `@sequenceholdings/*` specs in git-service repos, because remote installs only accept registry semver specs. Inline SVG components in `src/icons.tsx`.
48
-
49
- ## Bridge methods
50
-
51
- Two primitives on `seq.api`:
52
-
53
- - `seq.api.fetch(path, options?)` returns `{ ok, status, body }` — does not throw on non-2xx. Use when you need the status (e.g. handle 409) or the raw envelope with extra fields like `meta`.
54
- - `seq.api.request(method, path, body?)` throws on non-2xx and returns parsed JSON. Sugar wrappers `seq.api.get / .post / .patch / .delete` are thin shells around `request`, so they also throw.
55
-
56
- Wrap reads/writes that should auto-throw through `unwrap()` in `src/lib/api.ts`. Drop to `seq.api.fetch` when you need pagination `meta` or status inspection.
57
-
58
- ## Lattice embed context
59
-
60
- When the artifact is served from a Lattice human node (the **Tasks/Inbox** task surface, or the builder Artifacts tab), `seq.lattice.context()` tells you which run/node/step you're rendering for:
61
-
62
- ```ts
63
- const { runId, nodeId, stepId, processId } = seq.lattice.context()
64
- ```
65
-
66
- It's **synchronous** (the host injects this into the sandbox up front — no bridge round trip) and each field is `null` when unavailable. The Inbox provides all four; the builder Artifacts tab has no live `stepId`; a standalone open has no context at all. Use it instead of hand-parsing `window.location` — that's the sanctioned, stable accessor, and you don't need to declare anything in `artifact.bundle.yml` for it. To read step-scoped data, pass `runId`/`stepId` to the relevant Atlas endpoint (which still authorizes the caller server-side).
7
+ @AGENTS.md
@@ -7,7 +7,13 @@ import './styles.css'
7
7
 
8
8
  const queryClient = new QueryClient()
9
9
 
10
- createRoot(document.getElementById('root')!).render(
10
+ const root = createRoot(document.getElementById('root')!)
11
+ // Platform (same-document) runtime: register so the host can unmount on
12
+ // navigation. No-op inside the sandboxed iframe (registry is never installed).
13
+ ;(window as Window & { __atlasArtifactReactRoots__?: Set<{ unmount: () => void }> })
14
+ .__atlasArtifactReactRoots__?.add(root)
15
+
16
+ root.render(
11
17
  <React.StrictMode>
12
18
  <QueryClientProvider client={queryClient}>
13
19
  <PortalContainerProvider>
@@ -1,11 +1,14 @@
1
1
  @import "tailwindcss";
2
2
  @import "tw-animate-css";
3
3
  @import "@sequenceholdings/atlas-ui/tokens.css";
4
+ @import "@sequenceholdings/atlas-ui/tokens.v2.css";
5
+ @import "@sequenceholdings/atlas-ui/theme.v2.css";
4
6
 
5
7
  /* Tailwind v4 skips node_modules during automatic source detection, so the
6
8
  utility classes compiled into atlas-ui's components are never generated under
7
- raw `pnpm dev`. The deploy build auto-injects this @source (build.ts's
8
- atlasUiCssResolverPlugin), but plain Vite dev does not — declare it here so
9
- scaffolded artifacts render atlas-ui correctly in local dev too. Idempotent
10
- with the build-time injection. */
9
+ raw `pnpm dev`. The deploy build auto-injects this @source and the v2 sheets
10
+ above when missing (build.ts atlas-ui-css-plugin); declare them here so
11
+ scaffolded artifacts render correctly in local vite dev too. Idempotent with
12
+ build-time injection. At DES-227 cutover, tokens.css becomes v2 — drop the
13
+ explicit tokens.v2 / theme.v2 imports (and the CLI stops injecting them). */
11
14
  @source "../node_modules/@sequenceholdings/atlas-ui/dist";