+
-
@TypeAbbrev(Row.Type)
+
@TypeIcon(Row.Type)
@Row.Title
@@ -22,12 +23,30 @@
[Parameter, EditorRequired] public NodeTreeRow Row { get; set; } = default!;
[Parameter] public int Depth { get; set; }
- private static string TypeAbbrev(NodeType t) => t switch
+ private static string TypeIcon(NodeType t) => t switch
{
- NodeType.Initiative => "INIT",
- NodeType.Feature => "FEAT",
- NodeType.Capability => "CAP",
- NodeType.Slice => "SLC",
- _ => t.ToString().ToUpperInvariant()
+ NodeType.Initiative => "◆",
+ NodeType.Feature => "●",
+ NodeType.Capability => "○",
+ NodeType.Slice => "▸",
+ _ => "•"
+ };
+
+ private static string TypeLabel(NodeType t) => t switch
+ {
+ NodeType.Initiative => "Initiative",
+ NodeType.Feature => "Feature",
+ NodeType.Capability => "Capability",
+ NodeType.Slice => "Slice",
+ _ => t.ToString()
+ };
+
+ private static string TypeClass(NodeType t) => t switch
+ {
+ NodeType.Initiative => "initiative",
+ NodeType.Feature => "feature",
+ NodeType.Capability => "capability",
+ NodeType.Slice => "slice",
+ _ => "node"
};
}
diff --git a/src/Loom.Web/Program.cs b/src/Loom.Web/Program.cs
index 68c3d79..36bd9f4 100644
--- a/src/Loom.Web/Program.cs
+++ b/src/Loom.Web/Program.cs
@@ -112,7 +112,11 @@
// ─── Blazor Server with interactive Server components ──────────────────
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
-builder.Services.AddSignalR();
+// 32 KB default kills the Blazor circuit when the artifact attach
+// dropzone forwards a pasted image as base64. Cap matches the
+// 25 MB upload limit enforced server-side, with headroom for base64
+// expansion and the JSON envelope.
+builder.Services.AddSignalR(o => o.MaximumReceiveMessageSize = 64 * 1024 * 1024);
builder.Services.AddControllers();
// ─── HTTP plumbing ─────────────────────────────────────────────────────
diff --git a/src/Loom.Web/wwwroot/app.css b/src/Loom.Web/wwwroot/app.css
index d7b1d77..47bc12d 100644
--- a/src/Loom.Web/wwwroot/app.css
+++ b/src/Loom.Web/wwwroot/app.css
@@ -263,13 +263,28 @@ a { color: inherit; text-decoration: none; }
}
.tree-row {
display: grid;
- grid-template-columns: 60px 1fr auto;
+ /* status-pulse · type-icon · title · phase-pill — single line so the
+ phase doesn't wrap below. The 1fr column absorbs slack. */
+ grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 14px;
padding: 10px 0 10px 16px;
border-bottom: 1px solid var(--border);
position: relative;
}
+.tree-type-icon {
+ font-size: 14px;
+ color: var(--text-2);
+ line-height: 1;
+ width: 1.2em;
+ text-align: center;
+ cursor: help;
+}
+.tree-row.tree-type-feature .tree-title { font-weight: 700; }
+.tree-row.tree-type-initiative .tree-title { font-weight: 700; letter-spacing: 0.01em; }
+.tree-row.tree-type-feature .tree-type-icon { color: var(--accent); }
+.tree-row.tree-type-initiative .tree-type-icon { color: var(--accent); }
+.tree-row.tree-type-capability .tree-type-icon { color: var(--text-3); }
.tree-row:last-child { border-bottom: none; }
.tree-row::before {
content: '';
@@ -488,3 +503,40 @@ button:disabled,
opacity: 0.5;
cursor: not-allowed;
}
+
+/* ─── Artifact attach: drop / paste / pick zone ──────────────────── */
+.attach-dropzone {
+ position: relative;
+ border: 1px dashed var(--border-2);
+ border-radius: 6px;
+ padding: 1rem;
+ margin-bottom: .5rem;
+ background: var(--surface);
+ transition: border-color .12s ease, background .12s ease;
+ min-height: 60px;
+}
+.attach-dropzone.is-active {
+ border-color: var(--accent);
+ background: var(--surface-2);
+}
+/* Invisible native file input that fills the dropzone — accepts both
+ clicks (file picker) and drops (browser-native handling). */
+.attach-dropzone-input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+ z-index: 1;
+}
+.attach-dropzone-content {
+ position: relative;
+ z-index: 0;
+}
+/* Text inside the dropzone shouldn't intercept clicks meant for the
+ overlay input; only interactive elements (buttons, links) opt back
+ in via z-index + pointer-events. */
+.attach-dropzone-content p { pointer-events: none; }
+.attach-dropzone-content button,
+.attach-dropzone-content a { pointer-events: auto; position: relative; z-index: 2; }
diff --git a/src/Loom.Web/wwwroot/js/attach-paste.js b/src/Loom.Web/wwwroot/js/attach-paste.js
new file mode 100644
index 0000000..8785b93
--- /dev/null
+++ b/src/Loom.Web/wwwroot/js/attach-paste.js
@@ -0,0 +1,74 @@
+// Paste-to-upload glue for FeatureWorkspace.razor's artifact attach
+// panel. Listens for `paste` events on the window (capture phase) and
+// forwards image clipboard items to a Blazor [JSInvokable] method as
+// base64. Pure-string interop because Blazor Server's byte[] interop
+// is awkward; size cap is enforced server-side.
+
+(function () {
+ if (window.loomAttachPaste) return; // module already loaded
+
+ console.log('[loom] attach-paste.js loaded');
+ const listeners = new Map(); // elementId -> { handler, dotNetRef }
+
+ function bytesToBase64(bytes) {
+ let binary = '';
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
+ }
+ return btoa(binary);
+ }
+
+ window.loomAttachPaste = function (elementId, dotNetRef, methodName) {
+ // If an older listener is still attached (e.g. the Blazor
+ // circuit reconnected and the previous DotNetObjectReference
+ // is now stale), tear it down before installing the new one
+ // so paste events go to the live circuit.
+ const existing = listeners.get(elementId);
+ if (existing) {
+ window.removeEventListener('paste', existing.handler, true);
+ listeners.delete(elementId);
+ }
+
+ const handler = async function (ev) {
+ const items = ev.clipboardData && ev.clipboardData.items;
+ if (!items) return;
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ if (item.kind !== 'file') continue;
+ const blob = item.getAsFile();
+ if (!blob) continue;
+ if (!blob.type || !blob.type.startsWith('image/')) continue;
+ ev.preventDefault();
+ console.log('[loom] paste image', blob.type, blob.size);
+ try {
+ const buf = await blob.arrayBuffer();
+ const b64 = bytesToBase64(new Uint8Array(buf));
+ const ext = blob.type.split('/')[1] || 'png';
+ const filename = blob.name && blob.name.length > 0
+ ? blob.name
+ : `pasted-${Date.now()}.${ext}`;
+ await dotNetRef.invokeMethodAsync(methodName, filename, blob.type, b64);
+ } catch (e) {
+ console.error('[loom] paste invoke failed', e);
+ }
+ break; // one image per paste
+ }
+ };
+
+ // Listen on window in capture phase so we fire even when the
+ // active element is something Blazor renders later (a
+ //
, a , etc.) and even when no editable
+ // element has focus.
+ window.addEventListener('paste', handler, true);
+ listeners.set(elementId, { handler, dotNetRef });
+ console.log('[loom] paste listener attached for', elementId);
+ };
+
+ window.loomAttachPasteOff = function (elementId) {
+ const entry = listeners.get(elementId);
+ if (!entry) return;
+ window.removeEventListener('paste', entry.handler, true);
+ listeners.delete(elementId);
+ };
+})();