feat(book): J3.a — render in/out ports + frameId badge on text frames

First step of the threading UI. Every text-frame layer in book mode
now shows:

- An 'in' port (small square, top-left corner) — turns green when
  another frame's nextFrame ref points at this one.
- An 'out' port (small square, bottom-right corner) — turns green
  when this frame's nextFrame points somewhere.
- A tiny '⌗ <frameId>' badge in the bottom-left so the user can see
  the frame's stable id while authoring.

Reads thread-link state from layer.nextFrame on each frame (no
book.threads[] consumer yet — that lands in J4 with the pagination
refactor). Legacy text-frame layers (created pre-J2) get a frameId
backfilled the first time they render, so old books gain the new
ports automatically.

Click handling is not yet wired — J3.b adds the load-cursor + link
flow. The ports are visible affordances and tooltips today.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-04 16:01:07 -04:00
1 parent 2110e724b9
commit 3cab9482e7
1 file changed
+85
+85
View File
@@ -2048,6 +2048,38 @@
.bookStarterCardLabel { font-size: 1rem; font-weight: 700; }
.bookStarterCardDims { font-size: .78rem; color: var(--muted); margin-top: 2px; }
.bookStarterCardDesc { font-size: .76rem; color: var(--ink); margin-top: 8px; line-height: 1.35; }
/* Phase J3: in / out ports for threaded text frames. The ports
are small square handles at the corners of every text-frame
layer; clicking out → in links the frames so chapter text
flows from one to the next. Colour signals state:
empty (slate) → unlinked
linked (green) → part of a thread
overset (red) → end of thread with overflow text */
.bookFramePort {
position: absolute;
width: 10px; height: 10px;
background: #fff;
border: 1.5px solid #475569;
box-sizing: border-box;
cursor: crosshair;
z-index: 50;
pointer-events: auto;
}
.bookFramePort.in { top: -6px; left: -6px; border-radius: 0; }
.bookFramePort.out { bottom: -6px; right: -6px; border-radius: 0; }
.bookFramePort.linked { background: #16a34a; border-color: #15803d; }
.bookFramePort.overset { background: #dc2626; border-color: #991b1b; }
.bookFramePort:hover { box-shadow: 0 0 0 2px rgba(96,165,250,.6); }
.bookFrameIdBadge {
position: absolute; bottom: 4px; left: 4px;
font-size: .58rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: rgba(71,85,105,.65);
background: rgba(255,255,255,.7);
padding: 1px 4px; border-radius: 2px;
pointer-events: none;
user-select: none;
}
.bookPageTile {
position: relative;
border: 1px solid var(--line); background: #fff;
@@ -22546,6 +22578,52 @@
ctx.restore();
}
// Phase J3.a — attach in/out ports + a small frameId badge to a
// text-frame layer's rendered wrapper. Reads thread-link state
// by scanning state.layers (any text-frame whose nextFrame ref
// points at this layer counts as "linked in"). Click handling
// for port-to-port threading lands in J3.b; for now the ports
// are visible affordances only.
function _attachBookFramePorts(el, layer) {
// The frame might be a legacy text-frame layer that pre-dates
// J2 — give it a frameId on the fly so the ports work.
if (!layer.frameId) layer.frameId = makeBookEntryId();
const target = String(layer.target || '');
const myFrameId = layer.frameId;
// Has this frame been linked OUT to a next frame?
const linkedOut = !!(layer.nextFrame && layer.nextFrame.frameId);
// Has any other text-frame's nextFrame pointed AT this frame?
let linkedIn = false;
for (const other of (state.layers || [])) {
if (other === layer) continue;
if (other.type !== 'shape' || other.shape?.kind !== 'text-frame') continue;
if (other.nextFrame?.frameId === myFrameId) { linkedIn = true; break; }
}
const inPort = document.createElement('div');
inPort.className = 'bookFramePort in' + (linkedIn ? ' linked' : '');
inPort.title = linkedIn
? 'In port — text flows in from a linked previous frame.'
: 'In port — empty. Future: click an "out" port elsewhere first, then this to link.';
inPort.dataset.framePort = 'in';
inPort.dataset.frameId = myFrameId;
el.appendChild(inPort);
const outPort = document.createElement('div');
outPort.className = 'bookFramePort out' + (linkedOut ? ' linked' : '');
outPort.title = linkedOut
? 'Out port — text continues into frame ' + String(layer.nextFrame.frameId).slice(0, 8) + '. Click to break the thread (J3.b+).'
: 'Out port — text ends here. Future: click to load and then click another frame\'s in port to link.';
outPort.dataset.framePort = 'out';
outPort.dataset.frameId = myFrameId;
el.appendChild(outPort);
// Tiny id chip so the user can see which frameId a frame has
// (useful while authoring threads + debugging). Hidden behind
// a `.bookFrameIdBadge` class so we can dim / hide later.
const badge = document.createElement('div');
badge.className = 'bookFrameIdBadge';
badge.textContent = '⌗ ' + String(myFrameId).slice(0, 6);
el.appendChild(badge);
}
function renderLayers() {
const overlay = $('layerOverlay');
const designerOverlay = $('designerLayerOverlay');
@@ -22615,6 +22693,13 @@
el.style.background = layer.color || COLOR_LAYER_DEFAULT_FILL;
} else if (layer.type === 'shape') {
renderShapeIntoElement(el, layer);
// Phase J3.a — visible in/out ports on every text-frame
// layer in book mode. Click handling lands in J3.b; for
// now they signal the surface is a text frame and show
// whether it's part of a thread.
if (layer.shape?.kind === 'text-frame' && state.templateMode === 'book') {
_attachBookFramePorts(el, layer);
}
} else if (layer.type === 'qr') {
renderQrIntoElement(el, layer);
} else if (layer.type === 'pixelart') {