Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 49 additions & 7 deletions src/Drawd.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { COLORS, FONTS, FONT_LINK, Z_INDEX } from "./styles/theme";
import { HEADER_HEIGHT, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT, FILE_EXTENSION, LEGACY_FILE_EXTENSION } from "./constants";
import { generateInstructionFiles } from "./utils/generateInstructionFiles";
Expand Down Expand Up @@ -87,9 +87,17 @@ export default function Drawd() {
const [screenGroups, setScreenGroups] = useState([]);
const [selectedScreenGroup, setSelectedScreenGroup] = useState(null);

// ── Navigation structure (derived from screenGroups) ─────────────────────
const navigationStructure = useMemo(() => {
const navStacks = screenGroups.filter((g) => g.type === "nav-stack");
if (navStacks.length === 0) return { type: null, stackGroupIds: [] };
if (navStacks.length === 1) return { type: "single-stack", stackGroupIds: [navStacks[0].id] };
return { type: "tab-bar", stackGroupIds: navStacks.map((g) => g.id) };
}, [screenGroups]);

// ── Screen group callbacks ────────────────────────────────────────────────
const addScreenGroup = useCallback((name, screenIds = [], color = COLORS.accent008) => {
const group = { id: generateId(), name, screenIds, color, folderHint: "" };
const addScreenGroup = useCallback((name, screenIds = [], color = COLORS.accent008, type = "feature-area") => {
const group = { id: generateId(), name, screenIds, color, folderHint: "", type, tabIndex: null, tabIcon: "", stackEntryScreenId: null };
setScreenGroups((prev) => [...prev, group]);
return group.id;
}, []);
Expand Down Expand Up @@ -161,7 +169,7 @@ export default function Drawd() {
const {
connectedFileName, saveStatus, isFileSystemSupported,
openFile, saveAs, saveNow, disconnect,
} = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups);
} = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, navigationStructure);

// ── File actions ───────────────────────────────────────────────────
const onOpen = useCallback(async () => {
Expand Down Expand Up @@ -346,7 +354,7 @@ export default function Drawd() {

// ── Import / export ────────────────────────────────────────────────────────────────
const { importConfirm, setImportConfirm, importFileRef, onExport, onImport, onImportFileChange, onImportReplace, onImportMerge } =
useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups });
useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, navigationStructure, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups });

// ── Keyboard shortcuts ──────────────────────────────────────────────────────────────
useKeyboardShortcuts({
Expand Down Expand Up @@ -397,6 +405,14 @@ export default function Drawd() {
clearSelection();
}, [canvasSelection, addScreenGroup, clearSelection]);

const handleStackSelection = useCallback(() => {
const selectedScreenIds = canvasSelection.filter((i) => i.type === "screen").map((i) => i.id);
if (selectedScreenIds.length === 0) return;
const gid = addScreenGroup("Nav Stack", selectedScreenIds, COLORS.accent008, "nav-stack");
updateScreenGroup(gid, { stackEntryScreenId: selectedScreenIds[0] });
clearSelection();
}, [canvasSelection, addScreenGroup, updateScreenGroup, clearSelection]);

const onDeleteSelection = useCallback(() => {
const screenIds = canvasSelection.filter((i) => i.type === "screen").map((i) => i.id);
const stickyIds = canvasSelection.filter((i) => i.type === "sticky").map((i) => i.id);
Expand Down Expand Up @@ -426,18 +442,19 @@ export default function Drawd() {
techStack,
dataModels,
screenGroups,
navigationStructure,
scopeScreenIds,
allScreens: screens,
warnings,
});
}, [screens, connections, documents, featureBrief, scopeScreenIds, taskLink, techStack, dataModels, screenGroups]);
}, [screens, connections, documents, featureBrief, scopeScreenIds, taskLink, techStack, dataModels, screenGroups, navigationStructure]);

const onGenerate = useCallback(() => {
if (screens.length === 0) return;
const scopedScreens = scopeScreenIds
? screens.filter((s) => scopeScreenIds.has(s.id))
: screens;
const warnings = validateInstructions(scopedScreens, connections, { documents });
const warnings = validateInstructions(scopedScreens, connections, { documents, screenGroups, navigationStructure });
const errors = warnings.filter((w) => w.level === "error");
if (
errors.length > 0 &&
Expand Down Expand Up @@ -789,6 +806,28 @@ export default function Drawd() {
>
+ Create new group…
</button>
<button
onClick={() => {
const gid = addScreenGroup("Nav Stack", [groupContextMenu.screenId], COLORS.accent008, "nav-stack");
updateScreenGroup(gid, { stackEntryScreenId: groupContextMenu.screenId });
setSelectedScreenGroup(gid);
setGroupContextMenu(null);
}}
style={{
display: "block",
width: "100%",
padding: "6px 14px",
background: "none",
border: "none",
color: "#56b6c2",
fontFamily: FONTS.mono,
fontSize: 12,
textAlign: "left",
cursor: "pointer",
}}
>
+ Create new nav stack…
</button>
<button
onClick={() => {
removeScreenFromGroup(groupContextMenu.screenId);
Expand Down Expand Up @@ -846,6 +885,8 @@ export default function Drawd() {
onUpdateCodeRef={updateScreenCodeRef}
onUpdateCriteria={updateScreenCriteria}
onUpdateStatus={updateScreenStatus}
navigationStructure={navigationStructure}
screenGroups={screenGroups}
/>
)}
</div>
Expand Down Expand Up @@ -955,6 +996,7 @@ export default function Drawd() {
count={canvasSelection.length}
onDelete={onDeleteSelection}
onGroup={onGroupSelection}
onStack={handleStackSelection}
onCancel={clearSelection}
/>
)}
Expand Down
9 changes: 8 additions & 1 deletion src/components/BatchSelectionBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const barBtn = {
fontFamily: FONTS.mono,
};

export function BatchSelectionBar({ count, onDelete, onGroup, onCancel }) {
export function BatchSelectionBar({ count, onDelete, onGroup, onStack, onCancel }) {
return (
<div
style={{
Expand Down Expand Up @@ -47,6 +47,13 @@ export function BatchSelectionBar({ count, onDelete, onGroup, onCancel }) {
Group
</button>

<button
onClick={onStack}
style={{ ...barBtn, background: "rgba(86,182,194,0.12)", color: "#56b6c2" }}
>
Stack
</button>

<button
onClick={onCancel}
style={{ ...barBtn, background: "transparent", color: COLORS.textMuted, border: `1px solid ${COLORS.border}` }}
Expand Down
101 changes: 89 additions & 12 deletions src/components/ScreenGroup.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,39 @@ function computeBounds(groupScreenIds, screens) {
};
}

const NAV_STACK_CYAN = "#56b6c2";
const NAV_STACK_BG = "rgba(86,182,194,0.08)";
const NAV_STACK_BORDER = "rgba(86,182,194,0.5)";
const NAV_STACK_BORDER_SELECTED = "rgba(86,182,194,0.9)";

export function ScreenGroup({ group, screens, onUpdate, onDelete, onMoveScreens, selected, onSelect }) {
const [isEditingName, setIsEditingName] = useState(false);
const [draftName, setDraftName] = useState(group.name);

const bounds = computeBounds(group.screenIds, screens);
if (!bounds) return null;

const color = group.color || COLORS.accent008;
const borderColor = group.color
? group.color.replace(/[\d.]+\)$/, "0.4)")
: COLORS.accent03;
const isNavStack = group.type === "nav-stack";

const color = isNavStack ? NAV_STACK_BG : (group.color || COLORS.accent008);
const borderColor = isNavStack
? (selected ? NAV_STACK_BORDER_SELECTED : NAV_STACK_BORDER)
: (group.color
? group.color.replace(/[\d.]+\)$/, selected ? "0.85)" : "0.4)")
: (selected ? COLORS.accent03.replace(/[\d.]+\)$/, "0.85)") : COLORS.accent03));

const memberScreens = screens.filter((s) => group.screenIds.includes(s.id));

const handleToggleType = (e) => {
e.stopPropagation();
const newType = isNavStack ? "feature-area" : "nav-stack";
const patch = { type: newType };
// Auto-detect entry screen: first screen with no incoming connections from within group
if (newType === "nav-stack" && !group.stackEntryScreenId && memberScreens.length > 0) {
patch.stackEntryScreenId = memberScreens[0].id;
}
onUpdate(group.id, patch);
};

return (
<div
Expand All @@ -47,9 +69,9 @@ export function ScreenGroup({ group, screens, onUpdate, onDelete, onMoveScreens,
width: bounds.width,
height: bounds.height,
background: color,
border: selected
? `2px solid ${borderColor.replace(/[\d.]+\)$/, "0.85)")}`
: `1.5px dashed ${borderColor}`,
border: isNavStack
? `2px solid ${borderColor}`
: (selected ? `2px solid ${borderColor}` : `1.5px dashed ${borderColor}`),
borderRadius: 14,
pointerEvents: "none",
zIndex: Z_INDEX.screenGroup,
Expand Down Expand Up @@ -85,7 +107,7 @@ export function ScreenGroup({ group, screens, onUpdate, onDelete, onMoveScreens,
}}
style={{
background: "rgba(0,0,0,0.5)",
border: `1px solid ${borderColor}`,
border: `1px solid ${isNavStack ? NAV_STACK_CYAN : borderColor}`,
borderRadius: 4,
color: COLORS.text,
fontFamily: FONTS.mono,
Expand All @@ -102,22 +124,76 @@ export function ScreenGroup({ group, screens, onUpdate, onDelete, onMoveScreens,
style={{
fontSize: 11,
fontWeight: 700,
color: COLORS.accentLight,
color: isNavStack ? NAV_STACK_CYAN : COLORS.accentLight,
fontFamily: FONTS.mono,
textTransform: "uppercase",
letterSpacing: "0.08em",
cursor: "text",
padding: "2px 6px",
background: "rgba(0,0,0,0.35)",
border: `1px solid ${borderColor}`,
border: `1px solid ${isNavStack ? NAV_STACK_BORDER : borderColor}`,
borderRadius: 4,
userSelect: "none",
}}
>
{group.name}
</span>
)}
{group.folderHint && (

{/* Type toggle */}
<button
onClick={handleToggleType}
title={isNavStack ? "Switch to feature area" : "Mark as nav stack"}
style={{
fontSize: 9,
fontWeight: 700,
fontFamily: FONTS.mono,
textTransform: "uppercase",
letterSpacing: "0.06em",
cursor: "pointer",
padding: "1px 5px",
borderRadius: 3,
lineHeight: 1.5,
color: isNavStack ? NAV_STACK_CYAN : COLORS.textDim,
background: isNavStack ? "rgba(86,182,194,0.15)" : "rgba(0,0,0,0.25)",
border: `1px solid ${isNavStack ? NAV_STACK_BORDER : "rgba(255,255,255,0.12)"}`,
}}
>
{isNavStack ? "STACK" : "AREA"}
</button>

{/* Entry screen picker (nav-stack only) */}
{isNavStack && memberScreens.length > 0 && (
<select
value={group.stackEntryScreenId || ""}
onChange={(e) => {
e.stopPropagation();
onUpdate(group.id, { stackEntryScreenId: e.target.value || null });
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
title="Entry screen for this stack"
style={{
fontSize: 9,
fontFamily: FONTS.mono,
background: "rgba(0,0,0,0.45)",
border: `1px solid ${NAV_STACK_BORDER}`,
borderRadius: 3,
color: NAV_STACK_CYAN,
padding: "1px 4px",
cursor: "pointer",
outline: "none",
maxWidth: 100,
}}
>
<option value="" style={{ color: COLORS.textDim }}>entry…</option>
{memberScreens.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
)}

{!isNavStack && group.folderHint && (
<span
style={{
fontSize: 9,
Expand All @@ -131,8 +207,9 @@ export function ScreenGroup({ group, screens, onUpdate, onDelete, onMoveScreens,
{group.folderHint}
</span>
)}

<button
onClick={() => onDelete(group.id)}
onClick={(e) => { e.stopPropagation(); onDelete(group.id); }}
style={{
background: "none",
border: "none",
Expand Down
52 changes: 51 additions & 1 deletion src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE } from "../styles/theme";
import { useState } from "react";
import { SIDEBAR_WIDTH } from "../constants";

export function Sidebar({ screen, screens, connections, onClose, onRename, onAddHotspot, onEditHotspot, onAddState, onSelectScreen, onUpdateStateName, onUpdateNotes, onUpdateCodeRef, onUpdateCriteria, onUpdateStatus, onUpdateTbd, onUpdateRoles }) {
export function Sidebar({ screen, screens, connections, onClose, onRename, onAddHotspot, onEditHotspot, onAddState, onSelectScreen, onUpdateStateName, onUpdateNotes, onUpdateCodeRef, onUpdateCriteria, onUpdateStatus, onUpdateTbd, onUpdateRoles, navigationStructure, screenGroups }) {
const [draftNotes, setDraftNotes] = useState(screen.notes || "");
const [notesScreenId, setNotesScreenId] = useState(screen.id);
const [draftCodeRef, setDraftCodeRef] = useState(screen.codeRef || "");
Expand Down Expand Up @@ -625,6 +625,56 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd
+ Add Tap Area
</button>

{/* Navigation info */}
{(() => {
const navType = navigationStructure?.type ?? null;
if (!navType) return null;
const stackGroupIds = navigationStructure?.stackGroupIds || [];
const navStackGroups = (screenGroups || []).filter(g => g.type === "nav-stack");
const screenStack = navStackGroups.find(g => g.screenIds.includes(screen.id));
if (!screenStack) return null;

const tabIndex = stackGroupIds.indexOf(screenStack.id);
const isEntry = screenStack.stackEntryScreenId === screen.id;

return (
<>
<h5
style={{
margin: "20px 0 8px",
color: COLORS.textMuted,
fontFamily: FONTS.mono,
fontSize: 10,
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
Navigation
</h5>
<div
style={{
padding: "8px 12px",
background: "rgba(86,182,194,0.06)",
border: "1px solid rgba(86,182,194,0.25)",
borderRadius: 8,
marginBottom: 4,
fontSize: 11,
fontFamily: FONTS.mono,
lineHeight: 1.6,
}}
>
<div style={{ color: "#56b6c2", fontWeight: 600 }}>{screenStack.name}</div>
{navType === "tab-bar" && tabIndex >= 0 && (
<div style={{ color: COLORS.textMuted }}>Tab {tabIndex + 1}</div>
)}
<div style={{ color: COLORS.textDim }}>
{isEntry ? "Entry screen" : "Pushed screen"}
</div>
</div>
</>
);
})()}

{/* Incoming connections */}
{incomingLinks.length > 0 && (
<>
Expand Down
1 change: 1 addition & 0 deletions src/components/ToolBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const StickyNoteIcon = () => (
</svg>
);


const TOOLS = [
{ id: "select", label: "Select", icon: SelectIcon, key: "V" },
{ id: "pan", label: "Pan", icon: PanIcon, key: "H" },
Expand Down
2 changes: 1 addition & 1 deletion src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const GITHUB_URL = "https://github.com/trmquang93/drawd";
export const DOMAIN = "drawd.app";

// ── File Format ──────────────────────────────
export const FILE_VERSION = 10;
export const FILE_VERSION = 11;
export const FILE_EXTENSION = ".drawd";
export const LEGACY_FILE_EXTENSION = ".flowforge";
export const DEFAULT_EXPORT_FILENAME = "flow-export";
Expand Down
Loading