feat(sequence): add opt-in column_fit for viewBox-relative lanes (#64)

Add an opt-in spread layout for Sequence participants so wide viewBoxes provide useful column distance and label room while the historical fixed layout remains the default. Include packaged validators, agent-facing guidance, regression coverage, and a fresh distributable archive.
This commit is contained in:
Daeyoung Jeong
2026-08-13 17:12:32 +08:00
committed by GitHub
parent a3bf80c25a
commit 45f0611dfc
8 changed files with 186 additions and 8 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -67,6 +67,7 @@ Read Mermaid for topology and meaning, then author fresh Archify JSON; do not me
- Omit `meta.legend` for the truthful `auto` default. When needed, use only `mode: auto|all|hidden` and renderer-supported `entries.<kind>.label|visible`; labels never change semantics.
- Match every reader-facing authored string to the language of the user's request, or the conversation's dominant language when the request is language-neutral. Apply it consistently to titles/subtitles, node/edge/boundary/lane/group text, guided-view labels/notes, legend label overrides, and card titles/items; use another language or bilingual copy only when the user asks.
- Preserve exact product names, code identifiers, commands, protocols, API paths, and environment names. They may remain English inside localized copy, but never justify leaving the surrounding explanatory prose in another language.
- For sequence diagrams, omit `meta.column_fit` for the stable `fixed` layout. Set it to `"spread"` when a wide viewBox would otherwise leave unused horizontal space or when meaningful participant labels do not fit the fixed boxes; do not shorten semantic labels before trying `spread`.
- Component types are `frontend`, `backend`, `database`, `cloud`, `security`, `messagebus`, and `external`; variants are `default`, `emphasis`, `security`, and `dashed`.
- Relationship labels are semantic data. When one collides, move the label, adjust the route or spacing, then shorten the wording while preserving meaning. Only delete a label when both endpoints fully imply the relationship and it contains no protocol, action, direction, synchronous/asynchronous behavior, or cross-boundary mechanism; explain why the deleted label is redundant. Never delete a meaningful label merely to pass `showcase`.
- Omit `meta.engineering_profile` by default. Region, cluster, and security boundary wording do not by themselves enable it. Enable `deployment-ownership` only when the user explicitly asks for a production deployment topology, ownership handoff, or fail-closed deployment review and the source facts are known. Once enabled, must not remove the engineering profile merely to pass validation; repair the facts or report the diagnostics truthfully.
+14 -3
View File
@@ -57,8 +57,9 @@ not create edge facts.
| Constant | Value |
|----------|-------|
| viewBox | default `[920, 760]`; schema minimum `[480, 480]` |
| Participant boxes | 86×54 at y 72; centers at x = 62 + index×108 |
| Participant count | last center + 43 must be ≤ width − 40 (8 fit at width 920) |
| Participant boxes | `fixed` (default): 86×54 at y 72; `spread`: viewBox-relative width from 86px up to 190px |
| Participant columns | `fixed`: centers at x = 62 + index×108; `spread`: columns distribute across the available viewBox width |
| Participant count | the last box must end at or before width − 40; layouts that cannot fit fail closed |
| Lifelines | from y 142 down to height − 65; band must be ≥120px tall |
| Message `y` range | `[160, height − 83]` |
| Message spacing | ≥28px vertical between messages that share horizontal space |
@@ -69,6 +70,15 @@ not create edge facts.
`segments[].from/to` and `activations[].from/to` are y pixel coordinates, not
participant ids; activations also require `to > from`.
### Column fit
Sequence diagrams use `meta.column_fit: "fixed"` by default so existing
documents keep their historical coordinates. Use `"spread"` when a wide
viewBox would otherwise leave empty space on the right or when meaningful
participant labels do not fit the fixed 86px boxes. Spread derives box width
and column distance from the viewBox while preserving participant order,
lifelines, and message semantics.
## Design Rules
- Put participants across the top, ordered by the story the reader should
@@ -79,7 +89,8 @@ participant ids; activations also require `to > from`.
- Use `return` for quiet response messages.
- Use `dashed` for async trace, event, logging, and non-blocking work.
- Use segments as light background guides; keep segment labels short.
- Keep labels short enough to fit in narrow previews.
- Keep labels concise, but try `meta.column_fit: "spread"` before shortening a
meaningful participant label just to fit the fixed boxes.
Schema violations exit non-zero with path-prefixed messages annotated with the
element's id or label. The renderer additionally fails when it can detect
+22 -4
View File
@@ -22,18 +22,36 @@ const { diagram: sequence, template, outPath } = loadDiagram({
const viewBox = sequence.meta?.viewBox || [920, 760];
// The timeline scales with viewBox height: a taller viewBox gains message room,
// a shorter one shrinks the readable band (validated below) instead of clipping.
// `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
// the fixed 108px gap, so a wide canvas gains column distance and label room
// rather than dead space on the right. The default stays "fixed" so existing
// diagrams keep their coordinates.
const columnFit = sequence.meta?.column_fit === 'spread' ? 'spread' : 'fixed';
const participantCount = Math.max(1, asArray(sequence.participants).length);
const sideMargin = 62;
const participantW = columnFit === 'spread'
? Math.max(86, Math.min(190, Math.round((viewBox[0] - sideMargin * 2) / participantCount) - 24))
: 86;
const colGap = columnFit === 'spread' && participantCount > 1
? Math.max(108, (viewBox[0] - 40 - sideMargin - participantW) / (participantCount - 1))
: 108;
const layout = {
topY: 72,
participantW: 86,
participantW,
participantH: 54,
lifelineTop: 142,
lifelineBottom: viewBox[1] - 65,
legendY: viewBox[1] - 54,
leftX: 62,
colGap: 108,
leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
colGap,
labelH: 16
};
const participantBoxWidthNote = columnFit === 'spread'
? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
: `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
const arrowClass = {
...arrowClassMap,
return: ['a-default', 'arrowhead']
@@ -144,7 +162,7 @@ function validateSequence() {
const availableTextW = availableNodeTextWidth(layout.participantW);
const minimumW = minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum);
if (minimumW > availableTextW) {
problems.push(`Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${participantTextFit.sublabelMinimum}px legible minimum, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (participant boxes are a fixed ${layout.participantW}px).`);
problems.push(`Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${participantTextFit.sublabelMinimum}px legible minimum, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (${participantBoxWidthNote}).`);
}
}
}
File diff suppressed because one or more lines are too long
+7
View File
@@ -25,6 +25,13 @@ in generated HTML. Omit it, or set `"none"`, for the default static output.
motion-forward presentation), `blueprint` (high-contrast engineering review),
or `editorial` (warm publication-style design review and documentation).
Presets change only viewer styling; they do not alter semantic IDs or geometry.
Sequence `meta` additionally accepts `column_fit`. The default `fixed` keeps
the historical 108px column gap and 86px participant boxes, so an authored
diagram renders at the same coordinates no matter how wide its viewBox is.
`spread` derives the gap and box width from the viewBox instead, which turns a
wide canvas into column distance and label room rather than empty space on the
right. Lane order, IDs, and message semantics are unchanged either way.
It may also include up to five guided `views`. Each view has a unique `id`, a
reader-facing `label`, a non-empty `focus` list of existing semantic node IDs,
and an optional short `note`.
+4
View File
@@ -55,6 +55,10 @@
"showcase"
]
},
"column_fit": {
"description": "Horizontal participant layout. Omit this field or use fixed for the stable 86px boxes and 108px gap. Use spread when a wide viewBox would leave unused horizontal space or meaningful participant labels do not fit the fixed boxes; spread derives wider boxes and gaps from the viewBox without changing participant order or message semantics.",
"enum": ["fixed", "spread"]
},
"views": {
"$ref": "common.schema.json#/$defs/guidedViews"
},
+137
View File
@@ -0,0 +1,137 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { textUnits } from '../renderers/shared/utils.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(__dirname, '..');
function renderOutcome(doc) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-column-fit-'));
const input = path.join(tmp, 'input.json');
const output = path.join(tmp, 'output.html');
fs.writeFileSync(input, JSON.stringify(doc));
try {
execFileSync('node', [
path.join(skillRoot, 'renderers/sequence/render-sequence.mjs'),
input,
output,
], { stdio: ['ignore', 'ignore', 'pipe'] });
return { code: 0, stderr: '', html: fs.readFileSync(output, 'utf8') };
} catch (err) {
return { code: err.status ?? 1, stderr: String(err.stderr || ''), html: '' };
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
function render(doc) {
const outcome = renderOutcome(doc);
assert.equal(outcome.code, 0, outcome.stderr);
return outcome.html;
}
function participantBoxes(html) {
return [...html.matchAll(/<rect x="([\d.]+)" y="72" width="([\d.]+)" height="54"/g)]
.map(([, x, width]) => ({ x: Number(x), width: Number(width) }))
.filter((box, index, all) => all.findIndex((other) => other.x === box.x) === index)
.sort((left, right) => left.x - right.x);
}
function wideSequence(columnFit) {
const meta = { title: 'Column fit', viewBox: [1320, 620] };
if (columnFit) meta.column_fit = columnFit;
return {
schema_version: 1,
diagram_type: 'sequence',
meta,
participants: [
{ id: 'browser', type: 'frontend', label: 'Browser' },
{ id: 'gateway', type: 'backend', label: 'Gateway' },
{ id: 'idp', type: 'security', label: 'IdP' },
{ id: 'api', type: 'backend', label: 'API' },
{ id: 'store', type: 'database', label: 'Store' }
],
messages: [
{ from: 'browser', to: 'gateway', y: 200, label: 'request' },
{ from: 'gateway', to: 'idp', y: 260, label: 'authorize' },
{ from: 'idp', to: 'api', y: 320, label: 'token' },
{ from: 'api', to: 'store', y: 380, label: 'read' }
]
};
}
test('fixed column fit keeps the historical 108px gap regardless of viewBox width', () => {
const boxes = participantBoxes(render(wideSequence()));
assert.equal(boxes.length, 5);
assert.equal(boxes[0].width, 86);
assert.equal(boxes[1].x - boxes[0].x, 108);
assert.equal(boxes.at(-1).x + boxes.at(-1).width < 600, true,
'fixed lanes stay packed on the left, leaving the wide canvas unused');
});
test('spread column fit uses the viewBox width and stays inside it', () => {
const boxes = participantBoxes(render(wideSequence('spread')));
assert.equal(boxes.length, 5);
assert.ok(boxes[0].width > 86, 'participant boxes widen with the available room');
assert.ok(boxes[1].x - boxes[0].x > 108, 'columns spread past the fixed gap');
assert.equal(boxes[0].x, 62, 'first lane keeps the side margin');
assert.ok(boxes.at(-1).x + boxes.at(-1).width <= 1320 - 40,
'last lane stays inside the viewBox with the reserved margin');
});
test('spread column fit is opt-in, so an unset value renders like fixed', () => {
assert.equal(render(wideSequence()), render(wideSequence('fixed')));
});
const wideLabel = 'Payment Gateway Service';
function labelledSequence(columnFit) {
const doc = wideSequence(columnFit);
doc.participants[1].label = wideLabel;
return doc;
}
test('a label the fixed box rejects fits the spread box on the same viewBox', () => {
const estimatedLabelW = textUnits(wideLabel) * 6.8;
assert.ok(estimatedLabelW > 86 + 6, 'the fixture label must actually exceed the fixed box');
const fixed = renderOutcome(labelledSequence());
assert.notEqual(fixed.code, 0, 'the fixed box still rejects a label it cannot hold');
assert.ok(fixed.stderr.includes(`Label "${wideLabel}"`), `expected the label in stderr:\n${fixed.stderr}`);
assert.ok(fixed.stderr.includes('86px participant box'), `expected the fixed box width in stderr:\n${fixed.stderr}`);
const spread = renderOutcome(labelledSequence('spread'));
assert.equal(spread.code, 0, spread.stderr);
const box = participantBoxes(spread.html)[1];
assert.ok(estimatedLabelW <= box.width + 6, `label ~${estimatedLabelW}px must fit the ${box.width}px spread box`);
assert.ok(spread.html.includes(`>${wideLabel}</text>`), 'the label renders unshortened');
});
test('the sublabel diagnostic reports the width in force, not the historical constant', () => {
const unrescuable = 'Payment authorization gateway detail text that stays far too long to shrink';
const doc = wideSequence('spread');
doc.participants[0].sublabel = unrescuable;
const { code, stderr } = renderOutcome(doc);
assert.notEqual(code, 0, 'a sublabel past the legible minimum is still rejected');
assert.match(stderr, /participant boxes are 190px for this viewBox width and 5 participants/);
assert.doesNotMatch(stderr, /boxes are a fixed/, 'spread must not quote the fixed layout');
});
test('the fast authoring path explains when to opt into spread', () => {
const schema = JSON.parse(fs.readFileSync(path.join(skillRoot, 'schemas/sequence.schema.json'), 'utf8'));
const description = schema.properties.meta.properties.column_fit.description;
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
const rendererReadme = fs.readFileSync(path.join(skillRoot, 'renderers/sequence/README.md'), 'utf8');
assert.match(description, /wide viewBox/);
assert.match(description, /meaningful participant labels/);
assert.match(skill, /do not shorten semantic labels before trying `spread`/);
assert.match(rendererReadme, /Use `"spread"` when a wide/);
assert.match(rendererReadme, /try `meta\.column_fit: "spread"` before shortening/);
});