Initial commit — design docs + Phase 0 validation harness
Sets up the Socrata project repo with: docs/ — strategy and design documents - idea.md: full product vision - implementation-plan.md: Phase 0 + Phase 1 MVP plan - phase-0-validation.md: 2-week validation experiment strategy - phase-0-plan.md: concrete Phase 0 build plan - phase-0-results.md: Phase 0 gate outcome — GO for MVP - sysml-modeling.md: metamodel + SE discipline + validation rules - socrates.md: agent character, surfaces, modes, prompts, lifecycle - sync.md: bidirectional text↔diagram sync engineering - design-source/: HTML/CSS/JS handoff bundle from Claude Design phase-0/ — validated harness (CLI, no UI, no DB) - LM Studio (local OpenAI-compatible) generation + detection + judge - PlantUML rendering for SysML model visualization - 10-seed corpus (8 working + 2 holdouts) - 5 corpus runs with iteration history in reports/ - Final gate: 10/10 pass, mean 4.32/5, holdouts validated Phase 1 MVP scope and milestones documented in implementation-plan.md.
This commit is contained in:
25
docs/design-source/socrata/README.md
Normal file
25
docs/design-source/socrata/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# CODING AGENTS: READ THIS FIRST
|
||||
|
||||
This is a **handoff bundle** from Claude Design (claude.ai/design).
|
||||
|
||||
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
|
||||
|
||||
## What you should do — IMPORTANT
|
||||
|
||||
**Read the chat transcripts first.** There are 1 chat transcript(s) in `socrata/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
|
||||
|
||||
**Read `socrata/project/Socrata.html` in full.** The user had this file open when they triggered the handoff, so it's almost certainly the primary design they want built. Read it top to bottom — don't skim. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
|
||||
|
||||
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
|
||||
|
||||
## About the design files
|
||||
|
||||
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
|
||||
|
||||
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
|
||||
|
||||
## Bundle contents
|
||||
|
||||
- `socrata/README.md` — this file
|
||||
- `socrata/chats/` — conversation transcripts (read these!)
|
||||
- `socrata/project/` — the `Socrata` project files (HTML prototypes, assets, components)
|
||||
512
docs/design-source/socrata/chats/chat1.md
Normal file
512
docs/design-source/socrata/chats/chat1.md
Normal file
@@ -0,0 +1,512 @@
|
||||
# App Design
|
||||
|
||||
_Started 2026-04-28 10:48 UTC_
|
||||
|
||||
---
|
||||
|
||||
## User
|
||||
|
||||
help me design this app
|
||||
|
||||
## Assistant
|
||||
|
||||
I'll help you design Socrata. This is a rich, ambitious product — before I start building, I need to understand what you want to focus on. Let me ask some targeted questions.
|
||||
|
||||
_[tool: questions_v2]_
|
||||
|
||||
## User
|
||||
|
||||
Questions answered:
|
||||
- starting_context: Use a thoughtful, IDE/document-app inspired aesthetic
|
||||
- primary_focus: The dual-canvas editor (text + SysML diagram, side-by-side, synced)
|
||||
- fidelity: Hi-fi clickable prototype (multiple screens, realistic interactions)
|
||||
- variations: 2–3 distinct aesthetic directions side-by-side
|
||||
- aesthetic_vibe: Distinctive and opinionated (something memorable, with character)
|
||||
- socrates_personality: Character-forward — a portrait/glyph that feels like a presence
|
||||
- diagram_style: Softened/modernized (rounded blocks, subtle shadows, friendlier)
|
||||
- markup_style: Decide for me
|
||||
- screens_to_include: Seed idea / onboarding interview with Socrates, Dual-canvas editor (the main workspace)
|
||||
- tweaks_wanted: Diagram style variants, Density (comfortable ↔ compact), Markup rendering style, Socrates presence level (subtle ↔ prominent)
|
||||
|
||||
## Assistant
|
||||
|
||||
Great, I have what I need. Let me plan and start building.
|
||||
|
||||
_[tool: update_todos]_
|
||||
|
||||
Let me think through the system before building.
|
||||
|
||||
**Design system (out loud):**
|
||||
- This is a thinking tool for rigorous PMs. Not slick SaaS — more like a workshop/atelier. Think: Linear's craft + Obsidian's document-first warmth + a touch of academic gravitas (Socrates).
|
||||
- Three aesthetic directions:
|
||||
1. **"Manuscript"** — warm parchment/cream, serif (Newsreader/Source Serif), ink-blue accent. Document-first, scholarly, contemplative. Socrates feels like a character in a book.
|
||||
2. **"Foundry"** — IDE-dense, dark slate w/ amber+teal accents, mono+sans. Linear-esque rigor. Socrates as a precise terminal companion.
|
||||
3. **"Atelier"** — light off-white with sage green + rust accents, geometric sans (e.g. Söhne-feel via Inter Tight or Geist) + serif callouts. Modern, crafted, opinionated.
|
||||
- All three use the same dual-canvas info architecture; only chrome/typography/color shift.
|
||||
- Socrates: a hand-drawn glyph / monogram (a Greek-feeling sigil) that pulses softly. Persistent dock on the right.
|
||||
- Diagram: softened blocks, dotted-grid bg, hand-feeling associations.
|
||||
- Markup in text: pill chips color-coded by type (block/property/association/requirement), with hover affordance.
|
||||
|
||||
Let me set up files and start building.
|
||||
|
||||
_[tool: copy_starter_component]_
|
||||
|
||||
_[tool: copy_starter_component]_
|
||||
|
||||
Now let me build the main file. I'll architect it as: one HTML file that loads the design canvas + 3 aesthetic variants of the dual-canvas editor and an onboarding seed flow.
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
Good. Now let me build the prototype. I'll start with the core dual-canvas editor component, then wrap it in design_canvas with three styled variants.
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
Now the diagram canvas component (shared across all three aesthetics, themed via CSS variables):
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Now the textual canvas (renders narrative with chip markup) and the Socrates dock:
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
Now the Socrates dock + main editor composition:
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Now the editor shell that composes everything together:
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Now the seed/onboarding screen and the main HTML composing the three aesthetics:
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Now the three aesthetic theme stylesheets and the main HTML.
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Now the three theme stylesheets:
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Now the main HTML that wires it all together with the design canvas:
|
||||
|
||||
_[tool: write_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: grep]_
|
||||
|
||||
I notice TweakSection takes `label` not `title`, and TweakRadio takes flat options array. Let me fix the main HTML, plus check the API:
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: fork_verifier_agent]_
|
||||
|
||||
I've designed Socrata as three distinct aesthetic directions, each presenting the dual-canvas editor and the Socrates-led seed onboarding.
|
||||
|
||||
**The three directions** (in `Socrata.html`):
|
||||
- **A · Manuscript** — parchment cream, Newsreader serif, ink-blue accent. Document-first; Socrates feels like a character in a book.
|
||||
- **B · Foundry** — dark slate IDE, amber + teal, Geist + JetBrains Mono. For PMs who think like engineers.
|
||||
- **C · Atelier** — off-white with sage + rust, Fraunces display + Inter Tight. Modern, crafted, opinionated.
|
||||
|
||||
**Architecture**: one shared component set (TopBar, LeftRail, TextCanvas with chip markup, DiagramCanvas with softened SysML blocks, SocratesDock with sigil + active thread + pending proposal) themed entirely via CSS variables. Same data drives all three.
|
||||
|
||||
**Tweaks panel** (toolbar toggle):
|
||||
- Diagram block style (Soft / Formal / Graph)
|
||||
- Density (Comfortable / Compact)
|
||||
- Markup rendering (Pill / Color-coded / Underline / Bracket)
|
||||
- Socrates presence (Subtle / Default / Prominent)
|
||||
|
||||
The Σ sigil for Socrates pulses gently and appears throughout — in the brand, the dock, the seed interview avatar, and the inline margin notes.
|
||||
|
||||
**Next steps to consider:** the change-proposal review modal (impact analysis flow), comments + threaded discussion on a model element, and the validation experiment screen with web-search results — happy to build any of these into the same canvas as additional artboards.
|
||||
|
||||
## User
|
||||
|
||||
Verifier agent check completed
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: fork_verifier_agent]_
|
||||
|
||||
Fixed the diagram crash (renamed the `style` prop to `variant` to avoid the spread bug). The three editor artboards now render properly across all three aesthetics.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
Added count indicators to the outline items — Constraints (3), Hypotheses (3), Open questions (5), Risks (2) — each color-coded by type (req/asm/question/risk) using the same palette tokens as the chips in the narrative. They sit right-aligned in each row and stay subtle in compact density.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
The user is right — `3` next to "Aristotle" in the model rail is opaque. It's the property count, but there's no affordance saying so. Let me make it explicit by adding a leading glyph and a tooltip.
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
The count badge now reads as `· 3` with a `properties` tooltip on hover, and the rail section header itself is labeled `Model · 6 blocks · props` so the meaning is explicit at the section level too.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
The user wants the Socrates dock moved from the right side to the left side of the editor. I'll restructure the shell-body layout.
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
I need to remove the duplicate dock placeholder. Let me check the file:
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: grep]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
Moved the Socrates dock to the leftmost column (now: Socrates · Outline/Model rail · Narrative · Diagram). The border treatment was flipped accordingly so the dock has a right border instead of a left one.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
I'll make the LeftRail collapsible — each section (Outline, Model, Requirements) should toggle, and the whole rail should also be collapsible to a thin strip.
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Now I need to handle the collapse for the grid layout, since when collapsed, the rail still takes 220px. Let me make the rail width adjust via a class on the shell.
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
The rail now has collapsible sections (Outline / Model / Requirements each toggle independently with a caret) and a top-right collapse button that shrinks the whole rail to a 36px strip with vertical OUT/MOD/REQ tags; click any tag or the › button to expand again.
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
I'll wait for the next instruction.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Removed the Read/Edit/Diff segmented control — the narrative canvas header is now clean with just the title and subtitle.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Tightened the LeftRail: smaller padding (8px), smaller font (11.5px / 10.5px for blocks), shorter row spacing (2–4px), narrower column (184px / 168px in compact). The rail is noticeably more compact now while still readable.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
The Socrates dock header is now more compact: smaller sigil (28px), tighter padding (8px 10px), shorter status line ("2 open threads"). Section padding and label sizes were reduced consistently across the dock.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Canvas headers now have balanced vertical padding (8px top/bottom) with proper line-height (1.3) and a 2px gap between title and subtitle, applied uniformly. The min-height adapts to compact density.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Removed the Σ sigil from the topbar brand — just "Socrata" wordmark now.
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Topbar is now minimal: just sync pill, history button, and avatar.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
The user wants better spacing on the SysML block headers (the «block» stereotype, title, and properties feel cramped). Let me adjust the diagram block layout.
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Improved the spacing inside the SysML blocks — moved the stereotype label down (15px), pushed the title to 28px, the divider line to 32px, and increased property line gap to 5px so each block reads with proper rhythm.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
The Socrates dock is now focused: just the header and the active conversation thread. Proposal and Watch list sections are gone.
|
||||
|
||||
_[tool: snip]_
|
||||
|
||||
Awaiting next instruction.
|
||||
|
||||
## User
|
||||
|
||||
Apply comment
|
||||
|
||||
## Assistant
|
||||
|
||||
The user wants the Socrates dock to fill its space well (no awkward gaps, conversation flowing to the bottom) and to demo an in-chat numbered options affordance — where Socrates surfaces choices the user can pick by number.
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
Now I need to update the dock styles AND remove the history button from the topbar:
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
That edit was a no-op. Let me redo it correctly — actually remove the button:
|
||||
|
||||
_[tool: grep]_
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
Now let me add the dock styles for the new layout:
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: update_todos]_
|
||||
|
||||
_[tool: grep]_
|
||||
|
||||
I see there are duplicate `.dock-thread` rules. The new one at line ~373 should win (it's later). Let me also remove the old `.dock-input` style if it's positioning weirdly inside the thread:
|
||||
|
||||
_[tool: grep]_
|
||||
|
||||
Good — the later `.dock-thread` rule at line 494 overrides mine. I need to delete the old block. Let me do it cleanly:
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
The duplicate `.dock-thread` and stale `.bubble` rules need to be replaced with proper specificity. Let me delete the old definitions:
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
_[tool: fork_verifier_agent]_
|
||||
|
||||
The user wants the numbered option tiles tighter. Let me reduce padding, gap, and font sizes:
|
||||
|
||||
_[tool: str_replace_edit]_
|
||||
|
||||
_[tool: done]_
|
||||
|
||||
Made the numbered options tighter — single-line rows with the label and sub on the same line (sub ellipses if too long), smaller number badge (15px), reduced padding, and tighter gap between rows.
|
||||
|
||||
## User
|
||||
|
||||
Verifier agent check completed
|
||||
|
||||
## Assistant
|
||||
|
||||
Verifier confirmed the dock layout, numbered options, and topbar cleanup all look right. Ready for your next direction.
|
||||
|
||||
211
docs/design-source/socrata/project/Socrata.html
Normal file
211
docs/design-source/socrata/project/Socrata.html
Normal file
@@ -0,0 +1,211 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Socrata — dual-canvas explorations</title>
|
||||
<meta name="viewport" content="width=1480" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="stylesheet" href="theme-manuscript.css" />
|
||||
<link rel="stylesheet" href="theme-foundry.css" />
|
||||
<link rel="stylesheet" href="theme-atelier.css" />
|
||||
|
||||
<style>
|
||||
body { background: #f0eee9; }
|
||||
/* Make each editor artboard fill its DCArtboard frame */
|
||||
.artboard-host {
|
||||
width: 100%; height: 100%; overflow: hidden;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.04);
|
||||
}
|
||||
.seed-host { width: 100%; height: 100%; overflow: hidden; }
|
||||
|
||||
/* Direction caption shown on each artboard above the chrome */
|
||||
.aes-caption {
|
||||
position: absolute; top: 10px; left: 14px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(0,0,0,0.55);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- React + Babel pinned -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="data.js"></script>
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||
<script type="text/babel" src="diagram.jsx"></script>
|
||||
<script type="text/babel" src="text-canvas.jsx"></script>
|
||||
<script type="text/babel" src="socrates.jsx"></script>
|
||||
<script type="text/babel" src="editor-shell.jsx"></script>
|
||||
<script type="text/babel" src="seed-screen.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"diagramStyle": "softened",
|
||||
"density": "compact",
|
||||
"markupStyle": "color",
|
||||
"presence": "default"
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
function ThemedEditor({ theme, caption, density, markupStyle, diagramStyle, presence }) {
|
||||
return (
|
||||
<div className={`artboard-host theme-${theme}`}>
|
||||
<div className="aes-caption">{caption}</div>
|
||||
<EditorShell
|
||||
data={window.SOCRATA_DATA}
|
||||
density={density}
|
||||
markupStyle={markupStyle}
|
||||
diagramStyle={diagramStyle}
|
||||
presence={presence}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemedSeed({ theme, caption }) {
|
||||
return (
|
||||
<div className={`seed-host theme-${theme}`}>
|
||||
<div className="aes-caption">{caption}</div>
|
||||
<SeedScreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DesignCanvas>
|
||||
<DCSection
|
||||
id="editor"
|
||||
title="Dual-canvas editor"
|
||||
subtitle="Three aesthetic directions for the main workspace. Same data, same components, different soul."
|
||||
>
|
||||
<DCArtboard id="manuscript" label="A · Manuscript — parchment, serif, ink-blue. Document-first." width={1480} height={920}>
|
||||
<ThemedEditor
|
||||
theme="manuscript"
|
||||
caption="A · Manuscript"
|
||||
density={tweaks.density}
|
||||
markupStyle={tweaks.markupStyle}
|
||||
diagramStyle={tweaks.diagramStyle}
|
||||
presence={tweaks.presence}
|
||||
/>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="foundry" label="B · Foundry — dark IDE, amber + teal. For PMs who think like engineers." width={1480} height={920}>
|
||||
<ThemedEditor
|
||||
theme="foundry"
|
||||
caption="B · Foundry"
|
||||
density={tweaks.density}
|
||||
markupStyle={tweaks.markupStyle}
|
||||
diagramStyle={tweaks.diagramStyle}
|
||||
presence={tweaks.presence}
|
||||
/>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="atelier" label="C · Atelier — off-white, sage + rust, Fraunces display. Crafted, opinionated." width={1480} height={920}>
|
||||
<ThemedEditor
|
||||
theme="atelier"
|
||||
caption="C · Atelier"
|
||||
density={tweaks.density}
|
||||
markupStyle={tweaks.markupStyle}
|
||||
diagramStyle={tweaks.diagramStyle}
|
||||
presence={tweaks.presence}
|
||||
/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="seed"
|
||||
title="Seed idea formation"
|
||||
subtitle="Onboarding — Socrates interviews the PM and a seed model emerges in the left rail."
|
||||
>
|
||||
<DCArtboard id="seed-manuscript" label="A · Manuscript" width={1480} height={920}>
|
||||
<ThemedSeed theme="manuscript" caption="A · Manuscript" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="seed-foundry" label="B · Foundry" width={1480} height={920}>
|
||||
<ThemedSeed theme="foundry" caption="B · Foundry" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="seed-atelier" label="C · Atelier" width={1480} height={920}>
|
||||
<ThemedSeed theme="atelier" caption="C · Atelier" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
|
||||
<TweaksPanel title="Tweaks">
|
||||
<TweakSection label="Diagram" />
|
||||
<TweakRadio
|
||||
label="Block style"
|
||||
value={tweaks.diagramStyle}
|
||||
onChange={(v) => setTweak('diagramStyle', v)}
|
||||
options={[
|
||||
{ value: 'softened', label: 'Soft' },
|
||||
{ value: 'formal', label: 'Formal' },
|
||||
{ value: 'graph', label: 'Graph' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<TweakSection label="Density" />
|
||||
<TweakRadio
|
||||
label="Layout"
|
||||
value={tweaks.density}
|
||||
onChange={(v) => setTweak('density', v)}
|
||||
options={[
|
||||
{ value: 'comfortable', label: 'Comfortable' },
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<TweakSection label="Markup rendering" />
|
||||
<TweakSelect
|
||||
label="In-text markup"
|
||||
value={tweaks.markupStyle}
|
||||
onChange={(v) => setTweak('markupStyle', v)}
|
||||
options={[
|
||||
{ value: 'pill', label: 'Pill (muted chip)' },
|
||||
{ value: 'color', label: 'Color-coded by type' },
|
||||
{ value: 'underline', label: 'Underline + glyph' },
|
||||
{ value: 'bracket', label: 'Bracket syntax (dimmed)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<TweakSection label="Socrates presence" />
|
||||
<TweakRadio
|
||||
label="Dock"
|
||||
value={tweaks.presence}
|
||||
onChange={(v) => setTweak('presence', v)}
|
||||
options={[
|
||||
{ value: 'subtle', label: 'Subtle' },
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'prominent', label: 'Prominent' },
|
||||
]}
|
||||
/>
|
||||
</TweaksPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
100
docs/design-source/socrata/project/data.js
Normal file
100
docs/design-source/socrata/project/data.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// Sample project content — a realistic PM idea: an AI study companion for university students.
|
||||
// Used to populate the textual narrative + diagram in all three aesthetic directions.
|
||||
|
||||
window.SOCRATA_DATA = {
|
||||
project: {
|
||||
name: "Aristotle",
|
||||
tagline: "AI study companion for first-year STEM students",
|
||||
scope: "Higher education · undergraduate",
|
||||
owner: "M. Chen · PM",
|
||||
branch: "main",
|
||||
lastSync: "2 min ago",
|
||||
},
|
||||
|
||||
// The narrative — a sequence of "tokens": plain prose, paragraph breaks,
|
||||
// headings, and inline markup chips referencing model elements.
|
||||
narrative: [
|
||||
{ type: "h1", text: "Problem framing" },
|
||||
{ type: "p", children: [
|
||||
{ t: "text", v: "First-year STEM students at large public universities frequently disengage from coursework not because the material is intractable, but because the " },
|
||||
{ t: "chip", kind: "block", id: "student", label: "Student" },
|
||||
{ t: "text", v: " lacks a low-stakes thinking partner during the long tail between lectures and office hours. The " },
|
||||
{ t: "chip", kind: "block", id: "course", label: "Course" },
|
||||
{ t: "text", v: " produces problem sets that assume mastery of prerequisite scaffolding, and a " },
|
||||
{ t: "chip", kind: "property", id: "selfEfficacy", label: "self_efficacy" },
|
||||
{ t: "text", v: " gap forms quickly." },
|
||||
]},
|
||||
{ type: "p", children: [
|
||||
{ t: "text", v: "Aristotle is a study companion that " },
|
||||
{ t: "chip", kind: "association", id: "guides", label: "guides" },
|
||||
{ t: "text", v: " the student through Socratic prompts rather than answers, scoped to their current " },
|
||||
{ t: "chip", kind: "block", id: "assignment", label: "Assignment" },
|
||||
{ t: "text", v: ". It refuses to produce solutions; it only produces questions calibrated to a student's evolving understanding." },
|
||||
]},
|
||||
{ type: "h2", text: "Constraints" },
|
||||
{ type: "p", children: [
|
||||
{ t: "chip", kind: "requirement", id: "REQ-001", label: "REQ-001" },
|
||||
{ t: "text", v: " The companion must never output a complete solution to a graded problem. " },
|
||||
{ t: "chip", kind: "requirement", id: "REQ-002", label: "REQ-002" },
|
||||
{ t: "text", v: " Response latency under 1.2s P50 to preserve flow. " },
|
||||
{ t: "chip", kind: "requirement", id: "REQ-003", label: "REQ-003" },
|
||||
{ t: "text", v: " Operates within FERPA boundaries; coursework never leaves institutional tenancy." },
|
||||
]},
|
||||
{ type: "h2", text: "Why now" },
|
||||
{ type: "p", children: [
|
||||
{ t: "text", v: "Two large public-university pilots indicated that students would adopt a tool that explicitly does not solve their homework — an inversion of the prevailing market." },
|
||||
]},
|
||||
],
|
||||
|
||||
// Model — the SysML graph. Positions are normalized 0–1 on a 720×460 board.
|
||||
blocks: [
|
||||
{ id: "student", label: "Student", kind: "block", x: 0.10, y: 0.18, w: 168, h: 96,
|
||||
properties: ["self_efficacy", "course_load", "prior_grade"] },
|
||||
{ id: "aristotle", label: "Aristotle", kind: "block", x: 0.42, y: 0.18, w: 184, h: 110,
|
||||
properties: ["interaction_style", "scope_window", "refusal_policy"] },
|
||||
{ id: "course", label: "Course", kind: "block", x: 0.74, y: 0.10, w: 168, h: 96,
|
||||
properties: ["syllabus", "prerequisites"] },
|
||||
{ id: "assignment", label: "Assignment", kind: "block", x: 0.74, y: 0.58, w: 168, h: 96,
|
||||
properties: ["due_at", "rubric", "graded"] },
|
||||
{ id: "instructor", label: "Instructor", kind: "actor", x: 0.10, y: 0.62, w: 144, h: 76,
|
||||
properties: ["policy_set"] },
|
||||
{ id: "ferpa", label: "FERPA boundary", kind: "constraint", x: 0.42, y: 0.66, w: 184, h: 72,
|
||||
properties: [] },
|
||||
],
|
||||
|
||||
associations: [
|
||||
{ id: "a1", from: "student", to: "aristotle", label: "consults", kind: "association" },
|
||||
{ id: "a2", from: "aristotle", to: "assignment", label: "scoped_to", kind: "association" },
|
||||
{ id: "a3", from: "course", to: "assignment", label: "contains", kind: "composition" },
|
||||
{ id: "a4", from: "instructor", to: "aristotle", label: "configures", kind: "association" },
|
||||
{ id: "a5", from: "aristotle", to: "ferpa", label: "obeys", kind: "constraint" },
|
||||
{ id: "a6", from: "student", to: "course", label: "enrolled_in", kind: "association" },
|
||||
],
|
||||
|
||||
// Side rail items
|
||||
assumptions: [
|
||||
{ id: "ASM-1", text: "Students will accept a tool that refuses to give answers", status: "open", linked: ["aristotle"] },
|
||||
{ id: "ASM-2", text: "Faculty will not classify Socratic prompts as academic dishonesty", status: "validated", linked: ["aristotle", "instructor"] },
|
||||
{ id: "ASM-3", text: "1.2s P50 latency is achievable with on-prem inference", status: "open", linked: ["aristotle"] },
|
||||
],
|
||||
risks: [
|
||||
{ id: "RSK-1", text: "Refusal policy circumvented via prompt injection", severity: "high", linked: ["aristotle"] },
|
||||
{ id: "RSK-2", text: "FERPA tenancy adds 4–6 weeks of procurement per institution", severity: "med", linked: ["ferpa"] },
|
||||
],
|
||||
|
||||
// Socrates' active thread
|
||||
socratesThread: [
|
||||
{ who: "socrates", text: "You've defined Aristotle as scoped to a single Assignment. What happens when a student wants to compare techniques across two assignments — does the scope expand, or does the student switch contexts?" },
|
||||
{ who: "user", text: "Switch contexts. We don't want cross-assignment leakage of solution patterns." },
|
||||
{ who: "socrates", text: "Good. That decision has an implication: the property scope_window on Aristotle becomes single-valued, and we may want a Constraint that forbids cross-Assignment retrieval. Should I draft that as a proposal?" },
|
||||
],
|
||||
|
||||
// Pending change proposal
|
||||
proposal: {
|
||||
title: "Add constraint: no cross-Assignment retrieval",
|
||||
impactedBlocks: ["aristotle", "assignment"],
|
||||
addedConstraints: 1,
|
||||
affectedRequirements: ["REQ-001"],
|
||||
iteration: 2,
|
||||
},
|
||||
};
|
||||
622
docs/design-source/socrata/project/design-canvas.jsx
Normal file
622
docs/design-source/socrata/project/design-canvas.jsx
Normal file
@@ -0,0 +1,622 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
||||
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
||||
// State persists to a .design-canvas.state.json sidecar via the host
|
||||
// bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
||||
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
||||
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
|
||||
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
'[data-dc-slot]:hover .dc-expand{opacity:1}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, focused
|
||||
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const srcIds = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (!aid) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if (e.ctrlKey) {
|
||||
// trackpad pinch (or explicit ctrl+wheel)
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
return (
|
||||
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px 56px' }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
<div className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) ctx.setFocus(`${ns}/${first}`);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
142
docs/design-source/socrata/project/diagram.jsx
Normal file
142
docs/design-source/socrata/project/diagram.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
// DiagramCanvas — softened SysML diagram (rounded blocks w/ compartments,
|
||||
// associations as gentle splines). Themed entirely via CSS variables read
|
||||
// from the wrapping aesthetic. Pure visual, with hover + selection.
|
||||
|
||||
function DiagramCanvas({ data, density = "comfortable", variant = "softened", focusBlockId, onSelect }) {
|
||||
const W = 720, H = 460;
|
||||
const pad = density === "compact" ? 8 : 12;
|
||||
const titleSize = density === "compact" ? 12 : 13;
|
||||
const propSize = density === "compact" ? 10.5 : 11.5;
|
||||
const stroke = variant === "formal" ? 1.4 : 1;
|
||||
|
||||
const blockMap = React.useMemo(
|
||||
() => Object.fromEntries(data.blocks.map((b) => [b.id, b])),
|
||||
[data]
|
||||
);
|
||||
|
||||
function blockRect(b) {
|
||||
return { x: b.x * W, y: b.y * H, w: b.w, h: b.h };
|
||||
}
|
||||
|
||||
// Compute a clean orthogonal-ish path between blocks: exit center→nearest edge,
|
||||
// then a soft S-curve.
|
||||
function pathFor(a) {
|
||||
const f = blockRect(blockMap[a.from]);
|
||||
const t = blockRect(blockMap[a.to]);
|
||||
const fc = { x: f.x + f.w / 2, y: f.y + f.h / 2 };
|
||||
const tc = { x: t.x + t.w / 2, y: t.y + t.h / 2 };
|
||||
// Exit/enter on horizontal axis if dx dominates, else vertical.
|
||||
const dx = tc.x - fc.x, dy = tc.y - fc.y;
|
||||
let p1, p2, mid;
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
p1 = { x: fc.x + Math.sign(dx) * f.w / 2, y: fc.y };
|
||||
p2 = { x: tc.x - Math.sign(dx) * t.w / 2, y: tc.y };
|
||||
const cx = (p1.x + p2.x) / 2;
|
||||
mid = `C ${cx} ${p1.y}, ${cx} ${p2.y}, ${p2.x} ${p2.y}`;
|
||||
} else {
|
||||
p1 = { x: fc.x, y: fc.y + Math.sign(dy) * f.h / 2 };
|
||||
p2 = { x: tc.x, y: tc.y - Math.sign(dy) * t.h / 2 };
|
||||
const cy = (p1.y + p2.y) / 2;
|
||||
mid = `C ${p1.x} ${cy}, ${p2.x} ${cy}, ${p2.x} ${p2.y}`;
|
||||
}
|
||||
return { d: `M ${p1.x} ${p1.y} ${mid}`, mid: { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 } };
|
||||
}
|
||||
|
||||
const radius = variant === "formal" ? 3 : variant === "graph" ? 14 : 8;
|
||||
|
||||
return (
|
||||
<div className="diagram-wrap" style={{ width: "100%", height: "100%", position: "relative", overflow: "hidden", background: "var(--diagram-bg)" }}>
|
||||
<div className="diagram-grid" />
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet" width="100%" height="100%" style={{ display: "block" }}>
|
||||
<defs>
|
||||
<marker id="arrow-soft" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 1 L 9 5 L 0 9 z" fill="var(--edge)" />
|
||||
</marker>
|
||||
<marker id="diamond-soft" viewBox="0 0 12 10" refX="11" refY="5" markerWidth="10" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 5 L 6 1 L 12 5 L 6 9 z" fill="var(--surface)" stroke="var(--edge)" strokeWidth="1" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Edges */}
|
||||
{data.associations.map((a) => {
|
||||
const p = pathFor(a);
|
||||
const isComposition = a.kind === "composition";
|
||||
const isConstraint = a.kind === "constraint";
|
||||
return (
|
||||
<g key={a.id}>
|
||||
<path
|
||||
d={p.d}
|
||||
fill="none"
|
||||
stroke="var(--edge)"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={isConstraint ? "4 4" : null}
|
||||
markerEnd={isComposition ? "url(#diamond-soft)" : "url(#arrow-soft)"}
|
||||
opacity={0.85}
|
||||
/>
|
||||
<g transform={`translate(${p.mid.x}, ${p.mid.y})`}>
|
||||
<rect x={-a.label.length * 3.4 - 5} y={-8} width={a.label.length * 6.8 + 10} height={16}
|
||||
rx={4} fill="var(--diagram-bg)" stroke="var(--edge-soft)" strokeWidth="0.75" />
|
||||
<text x={0} y={3} textAnchor="middle" fontSize="10.5" fontFamily="var(--font-mono)" fill="var(--edge-label)">
|
||||
{a.label}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Blocks */}
|
||||
{data.blocks.map((b) => {
|
||||
const r = blockRect(b);
|
||||
const focus = focusBlockId === b.id;
|
||||
const isConstraint = b.kind === "constraint";
|
||||
const isActor = b.kind === "actor";
|
||||
return (
|
||||
<g key={b.id} style={{ cursor: "pointer" }} onClick={() => onSelect && onSelect(b.id)}>
|
||||
{/* shadow */}
|
||||
{variant !== "formal" && (
|
||||
<rect x={r.x + 1.5} y={r.y + 3} width={r.w} height={r.h} rx={radius}
|
||||
fill="var(--shadow)" opacity="0.5" />
|
||||
)}
|
||||
<rect
|
||||
x={r.x}
|
||||
y={r.y}
|
||||
width={r.w}
|
||||
height={r.h}
|
||||
rx={radius}
|
||||
fill={isConstraint ? "var(--block-constraint-bg)" : isActor ? "var(--block-actor-bg)" : "var(--block-bg)"}
|
||||
stroke={focus ? "var(--accent)" : "var(--block-border)"}
|
||||
strokeWidth={focus ? 1.75 : stroke}
|
||||
strokeDasharray={isConstraint ? "5 3" : null}
|
||||
/>
|
||||
{/* Header bar */}
|
||||
{!isConstraint && (
|
||||
<line x1={r.x} y1={r.y + 32} x2={r.x + r.w} y2={r.y + 32} stroke="var(--block-divider)" strokeWidth="0.75" />
|
||||
)}
|
||||
{/* Stereotype */}
|
||||
<text x={r.x + pad} y={r.y + 15} fontSize="9" fontFamily="var(--font-mono)" fill="var(--muted)" letterSpacing="0.04em">
|
||||
«{isConstraint ? "constraint" : isActor ? "actor" : "block"}»
|
||||
</text>
|
||||
{/* Title */}
|
||||
<text x={r.x + pad} y={r.y + (isConstraint ? 40 : 28)} fontSize={titleSize} fontFamily="var(--font-display)" fontWeight="600" fill="var(--fg)">
|
||||
{b.label}
|
||||
</text>
|
||||
{/* Properties */}
|
||||
{!isConstraint && b.properties.slice(0, 4).map((p, i) => (
|
||||
<text key={i} x={r.x + pad} y={r.y + 50 + i * (propSize + 5)} fontSize={propSize} fontFamily="var(--font-mono)" fill="var(--muted-strong)">
|
||||
· {p}
|
||||
</text>
|
||||
))}
|
||||
{isConstraint && (
|
||||
<text x={r.x + pad} y={r.y + 58} fontSize={propSize} fontFamily="var(--font-mono)" fill="var(--muted-strong)">
|
||||
{"{ tenancy = institutional }"}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.DiagramCanvas = DiagramCanvas;
|
||||
210
docs/design-source/socrata/project/editor-shell.jsx
Normal file
210
docs/design-source/socrata/project/editor-shell.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
// EditorShell — the dual-canvas workspace, themed by the wrapping artboard.
|
||||
|
||||
function TopBar({ data, presence }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<div className="brand">
|
||||
<span className="brand-name">Socrata</span>
|
||||
</div>
|
||||
<div className="breadcrumbs">
|
||||
<span className="bc-sep">/</span>
|
||||
<span className="bc-item">{data.project.scope}</span>
|
||||
<span className="bc-sep">/</span>
|
||||
<span className="bc-item bc-active">{data.project.name}</span>
|
||||
<span className="bc-branch">
|
||||
<span className="bc-branch-glyph">⎇</span> {data.project.branch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="sync-pill">
|
||||
<span className="sync-dot" /> model in sync · {data.project.lastSync}
|
||||
</div>
|
||||
<div className="topbar-divider" />
|
||||
<div className="avatar">MC</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftRail({ data, focusBlockId, setFocusBlockId, density }) {
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const [open, setOpen] = React.useState({ outline: true, model: true, requirements: true });
|
||||
const toggle = (k) => setOpen((s) => ({ ...s, [k]: !s[k] }));
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<nav className="leftrail leftrail-collapsed">
|
||||
<button className="rail-collapse-btn" onClick={() => setCollapsed(false)} title="Expand rail">›</button>
|
||||
<div className="rail-collapsed-stack">
|
||||
<span className="rail-collapsed-tag" title="Outline">OUT</span>
|
||||
<span className="rail-collapsed-tag" title="Model · 6 blocks">MOD</span>
|
||||
<span className="rail-collapsed-tag" title="Requirements">REQ</span>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="leftrail">
|
||||
<div className="rail-section">
|
||||
<button className="rail-section-head" onClick={() => toggle("outline")}>
|
||||
<span className={`rail-caret ${open.outline ? "rail-caret-open" : ""}`}>▸</span>
|
||||
<span className="rail-label">Outline</span>
|
||||
</button>
|
||||
{open.outline && (
|
||||
<ul className="rail-list">
|
||||
<li className="rail-item rail-item-active">
|
||||
<span className="rail-item-text">Problem framing</span>
|
||||
</li>
|
||||
<li className="rail-item">
|
||||
<span className="rail-item-text">Constraints</span>
|
||||
<span className="rail-count rail-count-req">3</span>
|
||||
</li>
|
||||
<li className="rail-item">
|
||||
<span className="rail-item-text">Why now</span>
|
||||
</li>
|
||||
<li className="rail-item rail-item-muted">
|
||||
<span className="rail-item-text">Hypotheses</span>
|
||||
<span className="rail-count rail-count-asm">3</span>
|
||||
</li>
|
||||
<li className="rail-item rail-item-muted">
|
||||
<span className="rail-item-text">Open questions</span>
|
||||
<span className="rail-count rail-count-q">5</span>
|
||||
</li>
|
||||
<li className="rail-item rail-item-muted">
|
||||
<span className="rail-item-text">Risks</span>
|
||||
<span className="rail-count rail-count-risk">2</span>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="rail-section">
|
||||
<button className="rail-section-head" onClick={() => toggle("model")}>
|
||||
<span className={`rail-caret ${open.model ? "rail-caret-open" : ""}`}>▸</span>
|
||||
<span className="rail-label">Model</span>
|
||||
</button>
|
||||
{open.model && (
|
||||
<ul className="rail-list rail-blocks">
|
||||
{data.blocks.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
|
||||
onMouseEnter={() => setFocusBlockId(b.id)}
|
||||
onMouseLeave={() => setFocusBlockId(null)}
|
||||
>
|
||||
<span className="rail-block-glyph">
|
||||
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"}
|
||||
</span>
|
||||
<span className="rail-block-label">{b.label}</span>
|
||||
<span
|
||||
className="rail-block-count"
|
||||
title={`${b.properties.length} ${b.properties.length === 1 ? "property" : "properties"}`}
|
||||
>
|
||||
<span className="rail-block-count-glyph">·</span>
|
||||
{b.properties.length}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="rail-section">
|
||||
<button className="rail-section-head" onClick={() => toggle("requirements")}>
|
||||
<span className={`rail-caret ${open.requirements ? "rail-caret-open" : ""}`}>▸</span>
|
||||
<span className="rail-label">Requirements</span>
|
||||
</button>
|
||||
{open.requirements && (
|
||||
<ul className="rail-list">
|
||||
<li className="rail-req"><span className="req-tag">REQ-001</span><span className="req-status req-traced" /></li>
|
||||
<li className="rail-req"><span className="req-tag">REQ-002</span><span className="req-status req-traced" /></li>
|
||||
<li className="rail-req"><span className="req-tag">REQ-003</span><span className="req-status req-untraced" /></li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<button className="rail-collapse-btn rail-collapse-btn-bottom" onClick={() => setCollapsed(true)} title="Collapse rail">‹</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasHeader({ title, subtitle, right }) {
|
||||
return (
|
||||
<div className="canvas-header">
|
||||
<div>
|
||||
<div className="canvas-title">{title}</div>
|
||||
<div className="canvas-sub">{subtitle}</div>
|
||||
</div>
|
||||
<div className="canvas-header-right">{right}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorShell({ data, density, markupStyle, diagramStyle, presence }) {
|
||||
const [focusBlockId, setFocusBlockId] = React.useState(null);
|
||||
|
||||
return (
|
||||
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
|
||||
<TopBar data={data} presence={presence} />
|
||||
<div className="shell-body">
|
||||
<SocratesDock data={data} presence={presence} density={density} />
|
||||
|
||||
<LeftRail data={data} focusBlockId={focusBlockId} setFocusBlockId={setFocusBlockId} density={density} />
|
||||
|
||||
<main className="canvases">
|
||||
<section className="canvas canvas-text">
|
||||
<CanvasHeader
|
||||
title="Narrative"
|
||||
subtitle="Markup-augmented prose · synced to model"
|
||||
right={null}
|
||||
/>
|
||||
<div className="canvas-scroll">
|
||||
<TextCanvas
|
||||
data={data}
|
||||
density={density}
|
||||
markupStyle={markupStyle}
|
||||
focusBlockId={focusBlockId}
|
||||
setFocusBlockId={setFocusBlockId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="canvas-divider" />
|
||||
|
||||
<section className="canvas canvas-diagram">
|
||||
<CanvasHeader
|
||||
title="Model"
|
||||
subtitle="SysML · 6 blocks · 6 associations · 1 constraint"
|
||||
right={
|
||||
<div className="canvas-actions">
|
||||
<span className="canvas-mode-pill">Fit</span>
|
||||
<span className="canvas-mode-pill canvas-mode-active">100%</span>
|
||||
<span className="canvas-mode-pill">Layout</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="canvas-scroll canvas-scroll-diagram">
|
||||
<DiagramCanvas data={data} density={density} variant={diagramStyle} focusBlockId={focusBlockId} onSelect={setFocusBlockId} />
|
||||
<div className="diagram-legend">
|
||||
<span><span className="legend-sw legend-block" /> block</span>
|
||||
<span><span className="legend-sw legend-actor" /> actor</span>
|
||||
<span><span className="legend-sw legend-constraint" /> constraint</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<footer className="statusbar">
|
||||
<span>Socrata · v0.4 · Phase 1</span>
|
||||
<span className="status-spacer" />
|
||||
<span>3 assumptions open · 2 risks tracked · 1 proposal pending</span>
|
||||
<span className="status-spacer" />
|
||||
<span>{data.project.owner}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.EditorShell = EditorShell;
|
||||
137
docs/design-source/socrata/project/seed-screen.jsx
Normal file
137
docs/design-source/socrata/project/seed-screen.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// SeedScreen — the onboarding interview with Socrates.
|
||||
// One screen, two-column: a "form" of fields filling in on the left as Socrates
|
||||
// asks questions on the right, plus a live preview of the seed model emerging.
|
||||
|
||||
function SeedScreen({ density }) {
|
||||
const [step, setStep] = React.useState(2); // pretend we're 2 questions in
|
||||
|
||||
const fields = [
|
||||
{ key: "problem", label: "Problem", value: "First-year STEM students disengage in the long tail between lectures and office hours; a self-efficacy gap forms quickly." },
|
||||
{ key: "user", label: "Target user", value: "Undergraduates at large public universities, weeks 3–10 of an intro course." },
|
||||
{ key: "outcome", label: "Desired outcome", value: "Students re-engage with material via a low-stakes thinking partner — without producing solutions." },
|
||||
{ key: "hypothesis", label: "Initial hypothesis", value: "Students will adopt a tool that explicitly refuses to solve their homework.", inferred: true },
|
||||
{ key: "constraint", label: "Constraint", value: "FERPA tenancy, P50 < 1.2s.", inferred: true },
|
||||
];
|
||||
|
||||
const thread = [
|
||||
{ who: "socrates", text: "Welcome. I'm Socrates. Before we model anything, let me understand what you're really proposing. In one sentence — what is the smallest, most honest version of the problem?" },
|
||||
{ who: "user", text: "Students disengage between lectures because they have nobody to think with at 11pm." },
|
||||
{ who: "socrates", text: "Good. Two follow-ups. First, who specifically — and why now? Second, when you say 'think with', do you mean a tutor that explains, or a partner that asks? These are quite different products." },
|
||||
{ who: "user", text: "Public-university undergrads, weeks 3–10. A partner that asks. The market is saturated with explainers." },
|
||||
{ who: "socrates", text: "Then the central tension is restraint: a tool that holds its tongue. Most LLM products are rewarded for being helpful. Yours will be rewarded for being patient. Should I draft this as a Constraint on the model — refusal_policy : single-valued — and surface it for your review?" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="seed-screen">
|
||||
<header className="seed-top">
|
||||
<div className="seed-top-left">
|
||||
<SocratesSigil size={28} />
|
||||
<span className="seed-brand">Socrata</span>
|
||||
<span className="seed-pip">·</span>
|
||||
<span className="seed-step">Seed · forming</span>
|
||||
</div>
|
||||
<div className="seed-top-right">
|
||||
<span className="seed-mode-pill seed-mode-active">Interview</span>
|
||||
<span className="seed-mode-pill">Form</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="seed-body">
|
||||
{/* Left: emerging seed */}
|
||||
<section className="seed-left">
|
||||
<div className="seed-section-label">Emerging seed</div>
|
||||
<div className="seed-fields">
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className={`seed-field ${f.inferred ? "seed-field-inferred" : ""}`}>
|
||||
<div className="seed-field-label">
|
||||
{f.label}
|
||||
{f.inferred && <span className="seed-conf">inferred · 0.74</span>}
|
||||
</div>
|
||||
<div className="seed-field-value">{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="seed-section-label seed-section-label-2">Initial model · drafting</div>
|
||||
<div className="seed-mini-graph">
|
||||
<div className="mini-block mini-block-1">
|
||||
<span className="mini-stereo">«block»</span>
|
||||
<span className="mini-name">Student</span>
|
||||
<span className="mini-prop">self_efficacy</span>
|
||||
</div>
|
||||
<div className="mini-edge"></div>
|
||||
<div className="mini-block mini-block-2 mini-block-focus">
|
||||
<span className="mini-stereo">«block»</span>
|
||||
<span className="mini-name">Aristotle</span>
|
||||
<span className="mini-prop">refusal_policy</span>
|
||||
<span className="mini-prop">interaction_style</span>
|
||||
</div>
|
||||
<div className="mini-edge mini-edge-down"></div>
|
||||
<div className="mini-block mini-block-3">
|
||||
<span className="mini-stereo">«constraint»</span>
|
||||
<span className="mini-name">FERPA boundary</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="seed-confidence">
|
||||
<div className="seed-confidence-row">
|
||||
<span>Model confidence</span>
|
||||
<span>0.62</span>
|
||||
</div>
|
||||
<div className="seed-confidence-bar">
|
||||
<div className="seed-confidence-fill" style={{ width: "62%" }} />
|
||||
</div>
|
||||
<div className="seed-confidence-hint">
|
||||
Three more clarifying questions should bring this above 0.80.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right: Socrates conversation */}
|
||||
<section className="seed-right">
|
||||
<div className="seed-thread">
|
||||
{thread.map((m, i) => (
|
||||
<div key={i} className={`seed-bubble seed-bubble-${m.who}`}>
|
||||
{m.who === "socrates" && (
|
||||
<div className="seed-bubble-avatar">
|
||||
<SocratesSigil size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="seed-bubble-body">
|
||||
<div className="seed-bubble-who">
|
||||
{m.who === "socrates" ? "Socrates" : "You"}
|
||||
</div>
|
||||
<div className="seed-bubble-text">{m.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="seed-bubble seed-bubble-socrates seed-bubble-typing">
|
||||
<div className="seed-bubble-avatar">
|
||||
<SocratesSigil size={32} />
|
||||
</div>
|
||||
<div className="seed-bubble-body">
|
||||
<div className="seed-bubble-who">Socrates</div>
|
||||
<div className="seed-typing">
|
||||
<span></span><span></span><span></span> drafting next question
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="seed-input-row">
|
||||
<div className="seed-input">
|
||||
<span className="seed-input-prompt">›</span>
|
||||
<span className="seed-input-text">Public-university undergrads, weeks 3–10. A partner that asks. The market is saturated with explainers.</span>
|
||||
<span className="seed-input-caret" />
|
||||
</div>
|
||||
<div className="seed-input-actions">
|
||||
<button className="seed-btn">Save draft</button>
|
||||
<button className="seed-btn seed-btn-primary">Send · ⌘↵</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.SeedScreen = SeedScreen;
|
||||
114
docs/design-source/socrata/project/socrates.jsx
Normal file
114
docs/design-source/socrata/project/socrates.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
// SocratesDock — character-forward AI presence. Sigil + thread + active proposal.
|
||||
|
||||
function SocratesSigil({ size = 44, mood = "thinking" }) {
|
||||
// A hand-drawn Greek-feeling monogram (Σ inside a softened laurel).
|
||||
return (
|
||||
<div className="sigil" style={{ width: size, height: size }}>
|
||||
<svg viewBox="0 0 64 64" width={size} height={size}>
|
||||
<defs>
|
||||
<radialGradient id="sigilGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="var(--accent-soft)" stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor="var(--accent-soft)" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="30" fill="url(#sigilGlow)" />
|
||||
<circle cx="32" cy="32" r="22" fill="var(--surface-2)" stroke="var(--accent)" strokeWidth="1.25" />
|
||||
{/* Laurel hint */}
|
||||
<path d="M 12 32 Q 18 18 32 14" fill="none" stroke="var(--accent)" strokeWidth="1" opacity="0.45" />
|
||||
<path d="M 52 32 Q 46 46 32 50" fill="none" stroke="var(--accent)" strokeWidth="1" opacity="0.45" />
|
||||
{/* Σ */}
|
||||
<text x="32" y="40" textAnchor="middle" fontSize="22"
|
||||
fontFamily="var(--font-display)" fontWeight="600" fill="var(--accent)">
|
||||
Σ
|
||||
</text>
|
||||
{mood === "thinking" && (
|
||||
<circle cx="50" cy="50" r="3" fill="var(--accent)" className="sigil-pulse" />
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SocratesDock({ data, presence, density }) {
|
||||
const compact = density === "compact";
|
||||
if (presence === "subtle") {
|
||||
return (
|
||||
<div className="dock dock-subtle">
|
||||
<SocratesSigil size={36} />
|
||||
<div className="dock-subtle-count">3</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build a richer thread that ends with a numbered-options prompt from
|
||||
// Socrates, so the user can see how chat-driven choice surfaces.
|
||||
const threadWithOptions = [
|
||||
...data.socratesThread,
|
||||
{
|
||||
who: "socrates",
|
||||
text: "Given the context-switch decision, three things follow. Pick where to go next:",
|
||||
options: [
|
||||
{ n: 1, label: "Draft the constraint", sub: "no cross-Assignment retrieval" },
|
||||
{ n: 2, label: "Revisit scope_window", sub: "single-valued vs. multi-valued" },
|
||||
{ n: 3, label: "Park it", sub: "I'll come back later" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className={`dock ${presence === "prominent" ? "dock-prominent" : "dock-default"}`}>
|
||||
<header className="dock-header">
|
||||
<SocratesSigil size={28} />
|
||||
<div className="dock-header-text">
|
||||
<div className="dock-name">Socrates</div>
|
||||
<div className="dock-status">
|
||||
<span className="dock-dot" /> 2 open threads
|
||||
</div>
|
||||
</div>
|
||||
<button className="dock-header-action" title="New thread">+</button>
|
||||
</header>
|
||||
|
||||
<section className="dock-thread-wrap">
|
||||
<div className="dock-section-label dock-section-label-inline">Active thread · Aristotle</div>
|
||||
<div className="dock-thread">
|
||||
{threadWithOptions.map((m, i) => (
|
||||
<div key={i} className={`bubble bubble-${m.who}`}>
|
||||
{m.who === "socrates" && <span className="bubble-sigil">Σ</span>}
|
||||
<span className="bubble-body">
|
||||
<span className="bubble-text">{m.text}</span>
|
||||
{m.options && (
|
||||
<div className="bubble-options">
|
||||
{m.options.map((o) => (
|
||||
<button key={o.n} className="bubble-option" type="button">
|
||||
<span className="bubble-option-num">{o.n}</span>
|
||||
<span className="bubble-option-text">
|
||||
<span className="bubble-option-label">{o.label}</span>
|
||||
<span className="bubble-option-sub">{o.sub}</span>
|
||||
</span>
|
||||
<span className="bubble-option-key">{o.n}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="bubble-options-hint">
|
||||
Press <kbd>1</kbd>–<kbd>3</kbd>, or type a reply
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="dock-input-wrap">
|
||||
<div className="dock-input">
|
||||
<span className="dock-input-prompt">›</span>
|
||||
<span className="dock-input-placeholder">Reply to Socrates…</span>
|
||||
<span className="dock-input-shortcut">⌘↵</span>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
window.SocratesDock = SocratesDock;
|
||||
window.SocratesSigil = SocratesSigil;
|
||||
820
docs/design-source/socrata/project/styles.css
Normal file
820
docs/design-source/socrata/project/styles.css
Normal file
@@ -0,0 +1,820 @@
|
||||
/* ─── Base shared styles (theme variables defined per-aesthetic in HTML) ─── */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { font-family: var(--font-body); color: var(--fg); background: var(--bg); }
|
||||
button { font-family: inherit; }
|
||||
|
||||
/* ─── Shell ─── */
|
||||
.shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
overflow: hidden;
|
||||
letter-spacing: var(--tracking);
|
||||
}
|
||||
.shell-density-compact { font-size: 12.5px; line-height: 1.45; }
|
||||
|
||||
/* ─── Top bar ─── */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-left { display: flex; align-items: center; gap: 14px; }
|
||||
.brand { display: flex; align-items: center; gap: 8px; }
|
||||
.brand-name { font-family: var(--font-display); font-weight: 600; font-size: 15px; letter-spacing: 0.01em; }
|
||||
.breadcrumbs { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); font-family: var(--font-mono); }
|
||||
.bc-sep { opacity: 0.45; }
|
||||
.bc-active { color: var(--fg); }
|
||||
.bc-branch { margin-left: 10px; padding: 2px 8px; border-radius: 4px; background: var(--accent-soft); color: var(--accent-strong); font-size: 11.5px; }
|
||||
.bc-branch-glyph { margin-right: 4px; }
|
||||
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
||||
.sync-pill { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--muted); font-family: var(--font-mono); }
|
||||
.sync-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
|
||||
.topbar-divider { width: 1px; height: 18px; background: var(--border); }
|
||||
.icon-btn {
|
||||
width: 26px; height: 26px; border-radius: 5px; border: 1px solid transparent;
|
||||
background: transparent; color: var(--muted); font-size: 13px;
|
||||
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-2); color: var(--fg); border-color: var(--border); }
|
||||
.avatar {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: var(--accent); color: var(--accent-on);
|
||||
font-size: 11px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-family: var(--font-display); letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ─── Body layout ─── */
|
||||
.shell-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 184px 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
.shell-density-compact .shell-body { grid-template-columns: 296px 168px 1fr; }
|
||||
.shell-presence-prominent .shell-body { grid-template-columns: 360px 184px 1fr; }
|
||||
.shell-presence-subtle .shell-body { grid-template-columns: 0px 184px 1fr; }
|
||||
|
||||
/* ─── Left rail ─── */
|
||||
.leftrail {
|
||||
border-right: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 8px 8px;
|
||||
overflow-y: auto;
|
||||
font-size: 11.5px;
|
||||
position: relative;
|
||||
}
|
||||
.leftrail-collapsed {
|
||||
padding: 14px 0;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
.rail-collapsed-stack {
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rail-collapsed-tag {
|
||||
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.08em;
|
||||
color: var(--muted); writing-mode: vertical-rl; text-orientation: mixed;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-collapsed-tag:hover { color: var(--fg); }
|
||||
.rail-section-head {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
width: 100%;
|
||||
background: transparent; border: none; padding: 0 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
color: inherit;
|
||||
height: 20px;
|
||||
}
|
||||
.rail-section-head:hover .rail-label { color: var(--fg); }
|
||||
.rail-caret { display: none; }
|
||||
.rail-section .rail-label { margin-bottom: 0; padding: 0; }
|
||||
.rail-collapse-btn {
|
||||
position: absolute; top: 8px; right: 6px;
|
||||
width: 18px; height: 18px; border-radius: 4px;
|
||||
border: none; background: transparent; color: var(--muted);
|
||||
cursor: pointer; font-size: 11px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.rail-collapse-btn:hover { background: var(--surface-2); color: var(--fg); }
|
||||
.leftrail-collapsed .rail-collapse-btn { position: static; }
|
||||
.rail-collapse-btn-bottom { display: none; }
|
||||
.shell-density-compact .leftrail { padding: 10px 10px; font-size: 11.5px; }
|
||||
.rail-section { margin-bottom: 10px; }
|
||||
.rail-label {
|
||||
text-transform: uppercase;
|
||||
font-size: 9.5px;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.10em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.rail-list { list-style: none; padding: 0; margin: 0; }
|
||||
.rail-item {
|
||||
padding: 2px 6px; border-radius: 3px; cursor: pointer; color: var(--fg);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 11.5px; line-height: 1.35;
|
||||
}
|
||||
.rail-item-text { flex: 1; }
|
||||
.rail-count {
|
||||
font-family: var(--font-mono); font-size: 9.5px;
|
||||
padding: 1px 5px; border-radius: 3px;
|
||||
background: var(--surface-2); color: var(--muted-strong);
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail-count-asm { background: var(--c-prop-bg); color: var(--c-prop-fg); }
|
||||
.rail-count-req { background: var(--c-block-bg); color: var(--c-block-fg); }
|
||||
.rail-count-q { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
.rail-count-risk { background: var(--warn-soft); color: var(--warn-strong); }
|
||||
.rail-item:hover { background: var(--surface-2); }
|
||||
.rail-item-active { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
.rail-item-muted { color: var(--muted); }
|
||||
|
||||
.rail-blocks { display: flex; flex-direction: column; gap: 1px; }
|
||||
.rail-block {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 2px 6px; border-radius: 3px; cursor: pointer;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.rail-block:hover { background: var(--surface-2); }
|
||||
.rail-block-active { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
.rail-block-glyph { color: var(--muted); width: 12px; }
|
||||
.rail-constraint .rail-block-glyph { color: var(--warn); }
|
||||
.rail-actor .rail-block-glyph { color: var(--info); }
|
||||
.rail-block-label { flex: 1; }
|
||||
.rail-block-count {
|
||||
font-size: 10px; color: var(--muted);
|
||||
background: var(--surface-2); padding: 1px 5px; border-radius: 3px;
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
cursor: help;
|
||||
}
|
||||
.rail-block-count-glyph { opacity: 0.6; }
|
||||
|
||||
.rail-req {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 2px 6px; font-family: var(--font-mono); font-size: 10px; color: var(--muted-strong);
|
||||
}
|
||||
.req-tag { letter-spacing: 0.04em; }
|
||||
.req-status { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.req-traced { background: var(--ok); }
|
||||
.req-untraced { background: var(--warn); }
|
||||
|
||||
/* ─── Canvases ─── */
|
||||
.canvases { display: grid; grid-template-columns: 1fr 1px 1fr; min-height: 0; background: var(--bg); }
|
||||
.canvas { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.canvas-divider { background: var(--border); }
|
||||
.canvas-header {
|
||||
height: auto; min-height: 44px; padding: 8px 16px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border); background: var(--surface);
|
||||
}
|
||||
.shell-density-compact .canvas-header { min-height: 38px; padding: 6px 12px; }
|
||||
.canvas-title { font-family: var(--font-display); font-weight: 600; font-size: 12.5px; letter-spacing: 0.02em; line-height: 1.3; }
|
||||
.canvas-sub { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); line-height: 1.3; margin-top: 2px; }
|
||||
.canvas-actions { display: flex; gap: 2px; background: var(--surface-2); padding: 2px; border-radius: 5px; }
|
||||
.canvas-mode-pill {
|
||||
font-size: 10.5px; padding: 3px 8px; border-radius: 4px;
|
||||
color: var(--muted); cursor: pointer; font-family: var(--font-mono);
|
||||
}
|
||||
.canvas-mode-active { background: var(--surface); color: var(--fg); box-shadow: 0 1px 2px var(--shadow); }
|
||||
|
||||
.canvas-scroll { flex: 1; overflow-y: auto; min-height: 0; position: relative; }
|
||||
.canvas-scroll-diagram { overflow: hidden; }
|
||||
|
||||
/* ─── Text canvas ─── */
|
||||
.text-canvas {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
font-family: var(--font-prose);
|
||||
color: var(--fg);
|
||||
}
|
||||
.t-h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 14px 0 16px 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
.t-h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.01em;
|
||||
margin: 24px 0 8px 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
.t-p {
|
||||
margin: 0 0 14px 0;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--prose);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.shell-density-compact .t-p { font-size: 13px; line-height: 1.55; margin-bottom: 10px; }
|
||||
.shell-density-compact .t-h1 { font-size: 19px; }
|
||||
.shell-density-compact .t-h2 { font-size: 13.5px; }
|
||||
|
||||
/* ─── Chips ─── */
|
||||
.chip {
|
||||
display: inline-flex; align-items: baseline; gap: 3px;
|
||||
padding: 1px 6px; border-radius: 4px; cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.86em;
|
||||
vertical-align: baseline;
|
||||
transition: background .12s, box-shadow .12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-glyph { font-size: 0.85em; opacity: 0.75; }
|
||||
.chip-label { font-weight: 500; }
|
||||
.chip-focus { box-shadow: 0 0 0 1.5px var(--accent); }
|
||||
|
||||
/* pill (default) */
|
||||
.chip-style-pill { background: var(--chip-bg); color: var(--chip-fg); }
|
||||
.chip-style-pill:hover { background: var(--chip-bg-hover); }
|
||||
|
||||
/* color-coded by kind */
|
||||
.chip-style-color.chip-block { background: var(--c-block-bg); color: var(--c-block-fg); }
|
||||
.chip-style-color.chip-property { background: var(--c-prop-bg); color: var(--c-prop-fg); }
|
||||
.chip-style-color.chip-association { background: var(--c-assoc-bg); color: var(--c-assoc-fg); }
|
||||
.chip-style-color.chip-requirement { background: var(--c-req-bg); color: var(--c-req-fg); }
|
||||
|
||||
/* underline */
|
||||
.chip-style-underline {
|
||||
background: transparent;
|
||||
padding: 0 1px;
|
||||
border-bottom: 1.5px solid var(--accent);
|
||||
border-radius: 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
.chip-style-underline.chip-block { border-bottom-color: var(--c-block-fg); }
|
||||
.chip-style-underline.chip-property { border-bottom-color: var(--c-prop-fg); }
|
||||
.chip-style-underline.chip-association { border-bottom-color: var(--c-assoc-fg); }
|
||||
.chip-style-underline.chip-requirement { border-bottom-color: var(--c-req-fg); }
|
||||
.chip-style-underline:hover { background: var(--chip-bg); }
|
||||
|
||||
/* bracket */
|
||||
.chip-style-bracket {
|
||||
background: transparent;
|
||||
color: var(--muted-strong);
|
||||
padding: 0 1px;
|
||||
}
|
||||
.chip-style-bracket .chip-bracket { color: var(--muted); opacity: 0.6; }
|
||||
.chip-style-bracket .chip-kind { color: var(--accent); margin-right: 3px; }
|
||||
.chip-style-bracket .chip-label { color: var(--fg); }
|
||||
.chip-style-bracket:hover { background: var(--chip-bg); border-radius: 3px; }
|
||||
|
||||
/* ─── Margin note ─── */
|
||||
.margin-note {
|
||||
display: flex; gap: 12px;
|
||||
margin: 24px 0 8px 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--note-bg);
|
||||
border-left: 2px solid var(--accent);
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-family: var(--font-prose);
|
||||
font-size: 13px;
|
||||
color: var(--prose);
|
||||
}
|
||||
.margin-note-glyph {
|
||||
font-family: var(--font-display); font-weight: 600;
|
||||
color: var(--accent); font-size: 17px; line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.margin-note-who {
|
||||
display: block;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--accent); letter-spacing: 0.04em; text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.margin-note-text { line-height: 1.5; }
|
||||
|
||||
/* ─── Diagram ─── */
|
||||
.diagram-grid {
|
||||
position: absolute; inset: 0;
|
||||
background-image: radial-gradient(var(--grid-dot) 1px, transparent 1px);
|
||||
background-size: 18px 18px;
|
||||
background-position: 0 0;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
.diagram-legend {
|
||||
position: absolute; bottom: 12px; left: 12px;
|
||||
display: flex; gap: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.legend-sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
|
||||
.legend-block { background: var(--block-bg); border: 1px solid var(--block-border); }
|
||||
.legend-actor { background: var(--block-actor-bg); border: 1px solid var(--block-border); }
|
||||
.legend-constraint { background: var(--block-constraint-bg); border: 1px dashed var(--block-border); }
|
||||
|
||||
/* ─── Socrates dock ─── */
|
||||
.dock {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
font-size: 12.5px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dock-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dock-header-text { flex: 1; min-width: 0; }
|
||||
.dock-header-action {
|
||||
width: 22px; height: 22px; border-radius: 4px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: transparent; color: var(--muted);
|
||||
cursor: pointer; font-size: 13px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.dock-header-action:hover { background: var(--surface-2); color: var(--fg); }
|
||||
|
||||
.dock-thread-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px 10px 6px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.dock-section-label-inline {
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dock-thread {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
.bubble {
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
font-family: var(--font-prose); font-size: 12.5px; line-height: 1.5;
|
||||
display: flex; gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.bubble-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.bubble-text { display: block; }
|
||||
|
||||
.dock-input-wrap {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.dock-input { margin-top: 0; }
|
||||
|
||||
/* Numbered options inside a bubble */
|
||||
.bubble-options {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.bubble-option {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 4px 7px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: var(--font-prose);
|
||||
color: var(--fg);
|
||||
width: 100%;
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.bubble-option:hover {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.bubble-option-num {
|
||||
width: 15px; height: 15px; flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-soft); color: var(--accent-strong);
|
||||
font-family: var(--font-mono); font-size: 9.5px; font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.bubble-option-text { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 6px; }
|
||||
.bubble-option-label {
|
||||
font-size: 11.5px; font-weight: 500; color: var(--fg);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.bubble-option-sub {
|
||||
font-family: var(--font-mono); font-size: 9.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bubble-option-key { display: none; }
|
||||
.bubble-options-hint {
|
||||
font-family: var(--font-mono); font-size: 9.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.bubble-options-hint kbd {
|
||||
font-family: var(--font-mono); font-size: 9px;
|
||||
padding: 0 3px; border-radius: 2px;
|
||||
background: var(--surface-2); color: var(--muted-strong);
|
||||
border: 1px solid var(--border);
|
||||
margin: 0 1px;
|
||||
}
|
||||
.dock-subtle {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
width: 50px; height: 50px;
|
||||
border-radius: 50%; border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 6px 20px var(--shadow-strong);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
.dock-subtle-count {
|
||||
position: absolute; top: -4px; right: -4px;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: var(--accent); color: var(--accent-on);
|
||||
font-size: 10px; font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.dock-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dock-name { font-family: var(--font-display); font-weight: 600; font-size: 12.5px; }
|
||||
.dock-status { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--muted); font-family: var(--font-mono); }
|
||||
.dock-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 2px var(--ok-soft); }
|
||||
|
||||
.dock-section { padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.dock-section:last-child { border-bottom: none; }
|
||||
.dock-section-label {
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.10em;
|
||||
color: var(--muted); margin-bottom: 6px;
|
||||
}
|
||||
.dock-section-label-2 { margin-top: 16px; }
|
||||
|
||||
.dock-thread { display: flex; flex-direction: column; gap: 8px; }
|
||||
.bubble-socrates {
|
||||
background: var(--bubble-socrates);
|
||||
color: var(--prose);
|
||||
border-left: 2px solid var(--accent);
|
||||
border-radius: 2px 8px 8px 2px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.bubble-socrates .bubble-sigil {
|
||||
display: inline-block;
|
||||
font-family: var(--font-display); font-weight: 600;
|
||||
color: var(--accent); margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bubble-user {
|
||||
background: var(--bubble-user);
|
||||
color: var(--fg);
|
||||
align-self: flex-end;
|
||||
max-width: 88%;
|
||||
}
|
||||
|
||||
.dock-input {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
}
|
||||
.dock-input-prompt { color: var(--accent); }
|
||||
.dock-input-placeholder { flex: 1; }
|
||||
.dock-input-shortcut { font-size: 10px; opacity: 0.55; }
|
||||
|
||||
/* Proposal */
|
||||
.proposal {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.proposal-title {
|
||||
font-family: var(--font-display); font-weight: 600; font-size: 12.5px;
|
||||
color: var(--fg); margin-bottom: 8px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.proposal-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
|
||||
.proposal-row { display: flex; gap: 8px; font-family: var(--font-mono); font-size: 10.5px; align-items: baseline; }
|
||||
.proposal-key { color: var(--muted); width: 70px; flex-shrink: 0; }
|
||||
.proposal-val { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.proposal-pill {
|
||||
background: var(--accent-soft); color: var(--accent-strong);
|
||||
padding: 1px 6px; border-radius: 3px; font-size: 10.5px;
|
||||
}
|
||||
.proposal-pill-req { background: var(--c-req-bg); color: var(--c-req-fg); }
|
||||
|
||||
.proposal-actions { display: flex; gap: 6px; }
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-on); border-color: var(--accent); }
|
||||
.btn-primary:hover { background: var(--accent-strong); }
|
||||
.btn-ghost { background: transparent; border-color: transparent; color: var(--muted); flex: 0; padding: 6px 8px; }
|
||||
|
||||
/* Watch list */
|
||||
.watch { display: flex; flex-direction: column; gap: 6px; }
|
||||
.watch-row {
|
||||
display: flex; gap: 8px; align-items: flex-start;
|
||||
font-size: 11.5px; line-height: 1.4; color: var(--prose);
|
||||
}
|
||||
.watch-tag {
|
||||
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.04em;
|
||||
background: var(--c-prop-bg); color: var(--c-prop-fg);
|
||||
padding: 2px 5px; border-radius: 3px;
|
||||
flex-shrink: 0; margin-top: 1px;
|
||||
}
|
||||
.watch-tag-risk { background: var(--warn-soft); color: var(--warn-strong); }
|
||||
.watch-validated .watch-tag { background: var(--ok-soft); color: var(--ok-strong); }
|
||||
.watch-text { flex: 1; }
|
||||
.watch-risk-high .watch-text { color: var(--fg); }
|
||||
|
||||
/* ─── Status bar ─── */
|
||||
.statusbar {
|
||||
height: 24px; flex-shrink: 0;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.status-spacer { flex: 1; }
|
||||
|
||||
/* ─── Sigil ─── */
|
||||
.sigil { position: relative; }
|
||||
.sigil-pulse {
|
||||
animation: sigilpulse 2.4s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
@keyframes sigilpulse {
|
||||
0%, 100% { opacity: 0.3; r: 2; }
|
||||
50% { opacity: 1; r: 3.5; }
|
||||
}
|
||||
|
||||
/* ─── Seed screen ─── */
|
||||
.seed-screen {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg);
|
||||
font-family: var(--font-body);
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.seed-top {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.seed-top-left { display: flex; align-items: center; gap: 10px; }
|
||||
.seed-brand { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
|
||||
.seed-pip { color: var(--muted); }
|
||||
.seed-step { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }
|
||||
.seed-top-right { display: flex; gap: 2px; background: var(--surface-2); padding: 2px; border-radius: 5px; }
|
||||
.seed-mode-pill {
|
||||
font-family: var(--font-mono); font-size: 11px; padding: 4px 12px;
|
||||
border-radius: 4px; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.seed-mode-active { background: var(--surface); color: var(--fg); box-shadow: 0 1px 2px var(--shadow); }
|
||||
|
||||
.seed-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 380px) 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.seed-left {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 22px 22px 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.seed-section-label {
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.14em;
|
||||
color: var(--muted); margin-bottom: 12px;
|
||||
}
|
||||
.seed-section-label-2 { margin-top: 24px; }
|
||||
.seed-fields { display: flex; flex-direction: column; gap: 14px; }
|
||||
.seed-field {
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
}
|
||||
.seed-field-inferred { border-left-color: var(--accent); border-left-style: dashed; }
|
||||
.seed-field-label {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--muted); letter-spacing: 0.04em;
|
||||
margin-bottom: 3px;
|
||||
display: flex; gap: 8px; align-items: baseline;
|
||||
}
|
||||
.seed-conf { font-size: 9.5px; color: var(--accent); opacity: 0.85; }
|
||||
.seed-field-value {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 13.5px; line-height: 1.5;
|
||||
color: var(--fg);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.seed-mini-graph {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
}
|
||||
.mini-block {
|
||||
border: 1px solid var(--block-border);
|
||||
background: var(--block-bg);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
}
|
||||
.mini-block-3 { background: var(--block-constraint-bg); border-style: dashed; }
|
||||
.mini-block-focus {
|
||||
border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.mini-stereo { font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); letter-spacing: 0.04em; }
|
||||
.mini-name { font-family: var(--font-display); font-weight: 600; font-size: 13px; }
|
||||
.mini-prop { font-family: var(--font-mono); font-size: 11px; color: var(--muted-strong); margin-top: 2px; }
|
||||
.mini-edge {
|
||||
height: 14px;
|
||||
background: linear-gradient(to bottom, var(--edge), var(--edge)) center/1px 100% no-repeat;
|
||||
margin-left: 18px;
|
||||
position: relative;
|
||||
}
|
||||
.mini-edge::after {
|
||||
content: ""; position: absolute; left: -3px; bottom: 0;
|
||||
border-left: 4px solid transparent; border-right: 4px solid transparent;
|
||||
border-top: 5px solid var(--edge);
|
||||
}
|
||||
|
||||
.seed-confidence { margin-top: 18px; }
|
||||
.seed-confidence-row {
|
||||
display: flex; justify-content: space-between;
|
||||
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.seed-confidence-bar {
|
||||
height: 4px; border-radius: 2px;
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.seed-confidence-fill {
|
||||
height: 100%; background: var(--accent);
|
||||
}
|
||||
.seed-confidence-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11px; color: var(--muted); font-family: var(--font-prose);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.seed-right {
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
.seed-thread {
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 22px 36px;
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.seed-bubble {
|
||||
display: flex; gap: 14px;
|
||||
}
|
||||
.seed-bubble-user { justify-content: flex-end; }
|
||||
.seed-bubble-avatar { flex-shrink: 0; padding-top: 2px; }
|
||||
.seed-bubble-body { max-width: 78%; }
|
||||
.seed-bubble-user .seed-bubble-body {
|
||||
background: var(--bubble-user);
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px 12px 2px 12px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.seed-bubble-who {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--muted); letter-spacing: 0.04em;
|
||||
text-transform: uppercase; margin-bottom: 4px;
|
||||
}
|
||||
.seed-bubble-user .seed-bubble-who { display: none; }
|
||||
.seed-bubble-text {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 14.5px; line-height: 1.6;
|
||||
color: var(--prose);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.seed-bubble-user .seed-bubble-text { color: var(--fg); }
|
||||
|
||||
.seed-bubble-typing .seed-bubble-text { color: var(--muted); }
|
||||
.seed-typing { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); font-family: var(--font-mono); }
|
||||
.seed-typing span {
|
||||
width: 4px; height: 4px; border-radius: 50%;
|
||||
background: var(--accent); opacity: 0.35;
|
||||
animation: typing 1.4s ease-in-out infinite;
|
||||
}
|
||||
.seed-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.seed-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
.seed-input-row {
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 14px 36px;
|
||||
max-width: 760px; margin: 0 auto; width: 100%;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.seed-input {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-prose);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--fg);
|
||||
}
|
||||
.seed-input-prompt { color: var(--accent); font-family: var(--font-mono); }
|
||||
.seed-input-text { flex: 1; }
|
||||
.seed-input-caret {
|
||||
display: inline-block;
|
||||
width: 1.5px; height: 16px;
|
||||
background: var(--accent);
|
||||
animation: caret 1s steps(1) infinite;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
@keyframes caret { 50% { opacity: 0; } }
|
||||
.seed-input-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.seed-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seed-btn-primary { background: var(--accent); color: var(--accent-on); border-color: var(--accent); }
|
||||
99
docs/design-source/socrata/project/text-canvas.jsx
Normal file
99
docs/design-source/socrata/project/text-canvas.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// TextCanvas — narrative editor surface with markup chips, headings, prose.
|
||||
// Renders chips per markupStyle: "pill" | "underline" | "bracket" | "color".
|
||||
|
||||
const CHIP_TYPE_LABEL = {
|
||||
block: "block",
|
||||
property: "property",
|
||||
association: "assoc",
|
||||
requirement: "req",
|
||||
};
|
||||
|
||||
function Chip({ kind, id, label, markupStyle, onHover, onClick, focused }) {
|
||||
const cls = `chip chip-${kind} chip-style-${markupStyle}${focused ? " chip-focus" : ""}`;
|
||||
const handlers = {
|
||||
onMouseEnter: () => onHover && onHover(id),
|
||||
onMouseLeave: () => onHover && onHover(null),
|
||||
onClick: () => onClick && onClick(id, kind),
|
||||
};
|
||||
if (markupStyle === "bracket") {
|
||||
return (
|
||||
<span className={cls} {...handlers}>
|
||||
<span className="chip-bracket">[</span>
|
||||
<span className="chip-kind">{CHIP_TYPE_LABEL[kind]}:</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
<span className="chip-bracket">]</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (markupStyle === "underline") {
|
||||
return (
|
||||
<span className={cls} {...handlers}>
|
||||
<span className="chip-glyph">{kindGlyph(kind)}</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// pill (default) and color both render as filled chips, color uses kind color, pill is muted.
|
||||
return (
|
||||
<span className={cls} {...handlers}>
|
||||
<span className="chip-glyph">{kindGlyph(kind)}</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function kindGlyph(kind) {
|
||||
switch (kind) {
|
||||
case "block": return "▢";
|
||||
case "property": return "·";
|
||||
case "association": return "→";
|
||||
case "requirement": return "§";
|
||||
default: return "·";
|
||||
}
|
||||
}
|
||||
|
||||
function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusBlockId, gutter }) {
|
||||
const padY = density === "compact" ? 10 : 18;
|
||||
return (
|
||||
<div className="text-canvas" style={{ padding: `${padY}px ${density === "compact" ? 22 : 36}px` }}>
|
||||
{gutter}
|
||||
{data.narrative.map((node, i) => {
|
||||
if (node.type === "h1") return <h1 key={i} className="t-h1">{node.text}</h1>;
|
||||
if (node.type === "h2") return <h2 key={i} className="t-h2">{node.text}</h2>;
|
||||
if (node.type === "p") {
|
||||
return (
|
||||
<p key={i} className="t-p">
|
||||
{node.children.map((c, j) => {
|
||||
if (c.t === "text") return <span key={j}>{c.v}</span>;
|
||||
if (c.t === "chip") return (
|
||||
<Chip key={j} kind={c.kind} id={c.id} label={c.label}
|
||||
markupStyle={markupStyle}
|
||||
focused={focusBlockId === c.id}
|
||||
onHover={setFocusBlockId}
|
||||
onClick={(id) => setFocusBlockId(id)}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* a Socrates inline annotation (margin note) */}
|
||||
<div className="margin-note">
|
||||
<span className="margin-note-glyph">Σ</span>
|
||||
<span>
|
||||
<span className="margin-note-who">Socrates · margin</span>
|
||||
<span className="margin-note-text">
|
||||
"Refuses to produce solutions" is a strong constraint. Have you decided what counts as a "solution" vs. a "scaffold"? This boundary will determine whether the refusal policy is enforceable.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.TextCanvas = TextCanvas;
|
||||
window.Chip = Chip;
|
||||
71
docs/design-source/socrata/project/theme-atelier.css
Normal file
71
docs/design-source/socrata/project/theme-atelier.css
Normal file
@@ -0,0 +1,71 @@
|
||||
/* ATELIER — light off-white, sage green + rust, modern crafted.
|
||||
Geometric sans + serif callouts. */
|
||||
|
||||
.theme-atelier {
|
||||
--font-display: "Fraunces", "Source Serif Pro", "Iowan Old Style", Georgia, serif;
|
||||
--font-prose: "Inter Tight", "Söhne", -apple-system, system-ui, sans-serif;
|
||||
--font-body: "Inter Tight", "Söhne", -apple-system, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
|
||||
--tracking: 0;
|
||||
|
||||
--bg: #fafaf6;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f0eee5;
|
||||
--fg: #1a1c19;
|
||||
--prose: #2e2f2a;
|
||||
--muted: #82857a;
|
||||
--muted-strong: #5a5d54;
|
||||
--border: #e4e1d6;
|
||||
--border-strong: #c8c4b3;
|
||||
|
||||
--accent: #5a7355; /* sage */
|
||||
--accent-strong: #3f5239;
|
||||
--accent-soft: #dfe5d9;
|
||||
--accent-on: #ffffff;
|
||||
|
||||
--ok: #5a7355;
|
||||
--ok-soft: #dfe5d9;
|
||||
--ok-strong: #3f5239;
|
||||
--warn: #b75a3f; /* rust */
|
||||
--warn-soft: #f0d9cf;
|
||||
--warn-strong: #7a3a25;
|
||||
--info: #6f8294;
|
||||
|
||||
--shadow: rgba(40,40,30,0.06);
|
||||
--shadow-strong: rgba(40,40,30,0.16);
|
||||
|
||||
--grid-dot: rgba(80,80,60,0.10);
|
||||
--diagram-bg: #f5f3ec;
|
||||
|
||||
--block-bg: #ffffff;
|
||||
--block-border: #c8c4b3;
|
||||
--block-divider: #e4e1d6;
|
||||
--block-actor-bg: #f0eee5;
|
||||
--block-constraint-bg:#f5e6dc;
|
||||
|
||||
--edge: #6a6b62;
|
||||
--edge-soft: #c8c4b3;
|
||||
--edge-label: #5a5d54;
|
||||
|
||||
--note-bg: #f0eee5;
|
||||
|
||||
--bubble-socrates: #f0eee5;
|
||||
--bubble-user: #dfe5d9;
|
||||
|
||||
--chip-bg: #f0eee5;
|
||||
--chip-bg-hover: #e4e1d6;
|
||||
--chip-fg: #2e2f2a;
|
||||
|
||||
--c-block-bg: #dfe5d9; --c-block-fg: #3f5239;
|
||||
--c-prop-bg: #ebe4cd; --c-prop-fg: #6b5a25;
|
||||
--c-assoc-bg: #d6e0e8; --c-assoc-fg: #3d556b;
|
||||
--c-req-bg: #f0d9cf; --c-req-fg: #7a3a25;
|
||||
}
|
||||
|
||||
/* Atelier flourishes: serif display headings, subtle italic callouts */
|
||||
.theme-atelier .t-h1 { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.015em; font-size: 26px; }
|
||||
.theme-atelier .t-h2 { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.005em; font-size: 17px; }
|
||||
.theme-atelier .brand-name { font-family: var(--font-display); font-weight: 500; }
|
||||
.theme-atelier .dock-name { font-family: var(--font-display); font-weight: 500; font-size: 16px; }
|
||||
.theme-atelier .canvas-title { font-family: var(--font-display); font-weight: 500; font-size: 14px; letter-spacing: 0; }
|
||||
.theme-atelier .seed-brand { font-family: var(--font-display); font-weight: 500; }
|
||||
69
docs/design-source/socrata/project/theme-foundry.css
Normal file
69
docs/design-source/socrata/project/theme-foundry.css
Normal file
@@ -0,0 +1,69 @@
|
||||
/* FOUNDRY — IDE-dense dark slate, amber + teal accents, mono-leaning.
|
||||
For PMs who think like engineers. */
|
||||
|
||||
.theme-foundry {
|
||||
--font-display: "Geist", "Inter Tight", -apple-system, system-ui, sans-serif;
|
||||
--font-prose: "Geist", "Inter Tight", -apple-system, system-ui, sans-serif;
|
||||
--font-body: "Geist", "Inter Tight", -apple-system, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", "Geist Mono", ui-monospace, Menlo, monospace;
|
||||
--tracking: 0.005em;
|
||||
|
||||
--bg: #0f1216;
|
||||
--surface: #15191f;
|
||||
--surface-2: #1c2128;
|
||||
--fg: #e6e2d8;
|
||||
--prose: #c8c2b3;
|
||||
--muted: #6f7480;
|
||||
--muted-strong: #9aa0ad;
|
||||
--border: #242a33;
|
||||
--border-strong: #353c47;
|
||||
|
||||
--accent: #d4a24a; /* amber */
|
||||
--accent-strong: #e8b75a;
|
||||
--accent-soft: #2a2519;
|
||||
--accent-on: #0f1216;
|
||||
|
||||
--ok: #5fb78f;
|
||||
--ok-soft: #1a2b25;
|
||||
--ok-strong: #7fd4ad;
|
||||
--warn: #e07a5f;
|
||||
--warn-soft: #2d1a16;
|
||||
--warn-strong: #f29478;
|
||||
--info: #5fb1d4;
|
||||
|
||||
--shadow: rgba(0,0,0,0.4);
|
||||
--shadow-strong: rgba(0,0,0,0.6);
|
||||
|
||||
--grid-dot: rgba(180,160,100,0.10);
|
||||
--diagram-bg: #11141a;
|
||||
|
||||
--block-bg: #1a1f27;
|
||||
--block-border: #3a4250;
|
||||
--block-divider: #2a3140;
|
||||
--block-actor-bg: #1c2530;
|
||||
--block-constraint-bg: #2a221a;
|
||||
|
||||
--edge: #8a8270;
|
||||
--edge-soft: #404550;
|
||||
--edge-label: #b8b0a0;
|
||||
|
||||
--note-bg: #1a1d22;
|
||||
|
||||
--bubble-socrates: #1a1d22;
|
||||
--bubble-user: #1f242c;
|
||||
|
||||
--chip-bg: #242a33;
|
||||
--chip-bg-hover: #2d3440;
|
||||
--chip-fg: #d4cfc0;
|
||||
|
||||
--c-block-bg: #1c2a35; --c-block-fg: #6fb5d6;
|
||||
--c-prop-bg: #2a2519; --c-prop-fg: #e8b75a;
|
||||
--c-assoc-bg: #1f2b25; --c-assoc-fg: #6fc9a0;
|
||||
--c-req-bg: #2d1a16; --c-req-fg: #f29478;
|
||||
}
|
||||
|
||||
/* Foundry-only: terminal feel, slightly tighter */
|
||||
.theme-foundry .canvas-header { font-feature-settings: "tnum"; }
|
||||
.theme-foundry .brand-name { letter-spacing: -0.005em; font-weight: 700; }
|
||||
.theme-foundry .topbar { border-bottom-color: var(--border-strong); }
|
||||
.theme-foundry .t-p { color: var(--prose); }
|
||||
79
docs/design-source/socrata/project/theme-manuscript.css
Normal file
79
docs/design-source/socrata/project/theme-manuscript.css
Normal file
@@ -0,0 +1,79 @@
|
||||
/* MANUSCRIPT — warm parchment, scholarly serif, ink-blue accent.
|
||||
Document-first; Socrates feels like a character in a book. */
|
||||
|
||||
.theme-manuscript {
|
||||
--font-display: "Newsreader", "Source Serif Pro", "Iowan Old Style", Georgia, serif;
|
||||
--font-prose: "Newsreader", "Source Serif Pro", "Iowan Old Style", Georgia, serif;
|
||||
--font-body: "Söhne", "Inter Tight", -apple-system, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
|
||||
--tracking: 0;
|
||||
|
||||
--bg: #f5efe2; /* parchment */
|
||||
--surface: #faf5e9;
|
||||
--surface-2: #ede4cf;
|
||||
--fg: #2a241a;
|
||||
--prose: #3d3527;
|
||||
--muted: #8a7c63;
|
||||
--muted-strong: #5e533f;
|
||||
--border: #d9ceb3;
|
||||
--border-strong: #b8a982;
|
||||
|
||||
--accent: #2c4a6b; /* ink blue */
|
||||
--accent-strong: #1c3550;
|
||||
--accent-soft: #d6def0;
|
||||
--accent-on: #faf5e9;
|
||||
|
||||
--ok: #5e7a3a;
|
||||
--ok-soft: #d9e3c7;
|
||||
--ok-strong: #3d5421;
|
||||
--warn: #a8551c;
|
||||
--warn-soft: #f0d9c2;
|
||||
--warn-strong: #6b3309;
|
||||
--info: #5b6e8a;
|
||||
|
||||
--shadow: rgba(60,40,15,0.08);
|
||||
--shadow-strong: rgba(60,40,15,0.18);
|
||||
|
||||
--grid-dot: rgba(120,90,40,0.18);
|
||||
--diagram-bg: #f0e9d6;
|
||||
|
||||
--block-bg: #fdfaf0;
|
||||
--block-border: #b8a982;
|
||||
--block-divider: #d9ceb3;
|
||||
--block-actor-bg: #f0e6cf;
|
||||
--block-constraint-bg:#f5dccb;
|
||||
|
||||
--edge: #6e5d3d;
|
||||
--edge-soft: #c8b88f;
|
||||
--edge-label: #5e533f;
|
||||
|
||||
--note-bg: #f0e3c4;
|
||||
|
||||
--bubble-socrates: #f0e3c4;
|
||||
--bubble-user: #ede4cf;
|
||||
|
||||
--chip-bg: #ede4cf;
|
||||
--chip-bg-hover: #e2d6b8;
|
||||
--chip-fg: #2a241a;
|
||||
|
||||
/* color-coded chips */
|
||||
--c-block-bg: #d6def0; --c-block-fg: #1c3550;
|
||||
--c-prop-bg: #e8e0c2; --c-prop-fg: #6b5a25;
|
||||
--c-assoc-bg: #d9e3c7; --c-assoc-fg: #3d5421;
|
||||
--c-req-bg: #f0d9c2; --c-req-fg: #6b3309;
|
||||
}
|
||||
|
||||
/* Manuscript-only flourishes: drop-cap-ish heading underline */
|
||||
.theme-manuscript .t-h1 {
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
padding-bottom: 8px;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.theme-manuscript .t-h2 {
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.theme-manuscript .brand-name { font-style: italic; }
|
||||
.theme-manuscript .canvas-title { font-style: italic; font-weight: 500; }
|
||||
425
docs/design-source/socrata/project/tweaks-panel.jsx
Normal file
425
docs/design-source/socrata/project/tweaks-panel.jsx
Normal file
@@ -0,0 +1,425 @@
|
||||
|
||||
// tweaks-panel.jsx
|
||||
// Reusable Tweaks shell + form-control helpers.
|
||||
//
|
||||
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||
//
|
||||
// Usage (in an HTML file that loads React + Babel):
|
||||
//
|
||||
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
// "primaryColor": "#D97757",
|
||||
// "fontSize": 16,
|
||||
// "density": "regular",
|
||||
// "dark": false
|
||||
// }/*EDITMODE-END*/;
|
||||
//
|
||||
// function App() {
|
||||
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
// return (
|
||||
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||
// Hello
|
||||
// <TweaksPanel>
|
||||
// <TweakSection label="Typography" />
|
||||
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||
// onChange={(v) => setTweak('fontSize', v)} />
|
||||
// <TweakRadio label="Density" value={t.density}
|
||||
// options={['compact', 'regular', 'comfy']}
|
||||
// onChange={(v) => setTweak('density', v)} />
|
||||
// <TweakSection label="Theme" />
|
||||
// <TweakColor label="Primary" value={t.primaryColor}
|
||||
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||
// <TweakToggle label="Dark mode" value={t.dark}
|
||||
// onChange={(v) => setTweak('dark', v)} />
|
||||
// </TweaksPanel>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const __TWEAKS_STYLE = `
|
||||
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||
background:rgba(250,249,247,.78);color:#29261b;
|
||||
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||
.twk-body::-webkit-scrollbar{width:8px}
|
||||
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||
border:2px solid transparent;background-clip:content-box}
|
||||
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||
border:2px solid transparent;background-clip:content-box}
|
||||
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||
color:rgba(41,38,27,.72)}
|
||||
.twk-lbl>span:first-child{font-weight:500}
|
||||
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||
|
||||
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||
.twk-sect:first-child{padding-top:0}
|
||||
|
||||
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||
select.twk-field{padding-right:22px;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right 8px center}
|
||||
|
||||
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||
|
||||
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||
background:rgba(0,0,0,.06);user-select:none}
|
||||
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||
overflow-wrap:anywhere}
|
||||
|
||||
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||
.twk-toggle[data-on="1"]{background:#34c759}
|
||||
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||
|
||||
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||
user-select:none;padding-right:8px}
|
||||
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||
outline:none;color:inherit;-moz-appearance:textfield}
|
||||
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||
-webkit-appearance:none;margin:0}
|
||||
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||
|
||||
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||
|
||||
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||
background:transparent;flex-shrink:0}
|
||||
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||
`;
|
||||
|
||||
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||
// Single source of truth for tweak values. setTweak persists via the host
|
||||
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||
function useTweaks(defaults) {
|
||||
const [values, setValues] = React.useState(defaults);
|
||||
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||
// JSON block.
|
||||
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||
? keyOrEdits : { [keyOrEdits]: val };
|
||||
setValues((prev) => ({ ...prev, ...edits }));
|
||||
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||
}, []);
|
||||
return [values, setTweak];
|
||||
}
|
||||
|
||||
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||
// availability — if the announce ran first, the host's activate could land
|
||||
// before our handler exists and the toolbar toggle would silently no-op.
|
||||
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||
// is what actually hides the panel.
|
||||
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const dragRef = React.useRef(null);
|
||||
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||
const PAD = 16;
|
||||
|
||||
const clampToViewport = React.useCallback(() => {
|
||||
const panel = dragRef.current;
|
||||
if (!panel) return;
|
||||
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||
offsetRef.current = {
|
||||
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||
};
|
||||
panel.style.right = offsetRef.current.x + 'px';
|
||||
panel.style.bottom = offsetRef.current.y + 'px';
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
clampToViewport();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', clampToViewport);
|
||||
return () => window.removeEventListener('resize', clampToViewport);
|
||||
}
|
||||
const ro = new ResizeObserver(clampToViewport);
|
||||
ro.observe(document.documentElement);
|
||||
return () => ro.disconnect();
|
||||
}, [open, clampToViewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onMsg = (e) => {
|
||||
const t = e?.data?.type;
|
||||
if (t === '__activate_edit_mode') setOpen(true);
|
||||
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
setOpen(false);
|
||||
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||
};
|
||||
|
||||
const onDragStart = (e) => {
|
||||
const panel = dragRef.current;
|
||||
if (!panel) return;
|
||||
const r = panel.getBoundingClientRect();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const startRight = window.innerWidth - r.right;
|
||||
const startBottom = window.innerHeight - r.bottom;
|
||||
const move = (ev) => {
|
||||
offsetRef.current = {
|
||||
x: startRight - (ev.clientX - sx),
|
||||
y: startBottom - (ev.clientY - sy),
|
||||
};
|
||||
clampToViewport();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
<style>{__TWEAKS_STYLE}</style>
|
||||
<div ref={dragRef} className="twk-panel"
|
||||
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||
<b>{title}</b>
|
||||
<button className="twk-x" aria-label="Close tweaks"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={dismiss}>✕</button>
|
||||
</div>
|
||||
<div className="twk-body">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSection({ label, children }) {
|
||||
return (
|
||||
<>
|
||||
<div className="twk-sect">{label}</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRow({ label, value, children, inline = false }) {
|
||||
return (
|
||||
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||
<div className="twk-lbl">
|
||||
<span>{label}</span>
|
||||
{value != null && <span className="twk-val">{value}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Controls ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||
return (
|
||||
<TweakRow label={label} value={`${value}${unit}`}>
|
||||
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakToggle({ label, value, onChange }) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||
role="switch" aria-checked={!!value}
|
||||
onClick={() => onChange(!value)}><i /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRadio({ label, value, options, onChange }) {
|
||||
const trackRef = React.useRef(null);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||
const n = opts.length;
|
||||
|
||||
// The active value is read by pointer-move handlers attached for the lifetime
|
||||
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||
const valueRef = React.useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
const segAt = (clientX) => {
|
||||
const r = trackRef.current.getBoundingClientRect();
|
||||
const inner = r.width - 4;
|
||||
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||
};
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
setDragging(true);
|
||||
const v0 = segAt(e.clientX);
|
||||
if (v0 !== valueRef.current) onChange(v0);
|
||||
const move = (ev) => {
|
||||
if (!trackRef.current) return;
|
||||
const v = segAt(ev.clientX);
|
||||
if (v !== valueRef.current) onChange(v);
|
||||
};
|
||||
const up = () => {
|
||||
setDragging(false);
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||
<div className="twk-seg-thumb"
|
||||
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||
width: `calc((100% - 4px) / ${n})` }} />
|
||||
{opts.map((o) => (
|
||||
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakSelect({ label, value, options, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => {
|
||||
const v = typeof o === 'object' ? o.value : o;
|
||||
const l = typeof o === 'object' ? o.label : o;
|
||||
return <option key={v} value={v}>{l}</option>;
|
||||
})}
|
||||
</select>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakText({ label, value, placeholder, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||
const clamp = (n) => {
|
||||
if (min != null && n < min) return min;
|
||||
if (max != null && n > max) return max;
|
||||
return n;
|
||||
};
|
||||
const startRef = React.useRef({ x: 0, val: 0 });
|
||||
const onScrubStart = (e) => {
|
||||
e.preventDefault();
|
||||
startRef.current = { x: e.clientX, val: value };
|
||||
const decimals = (String(step).split('.')[1] || '').length;
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startRef.current.x;
|
||||
const raw = startRef.current.val + dx * step;
|
||||
const snapped = Math.round(raw / step) * step;
|
||||
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
return (
|
||||
<div className="twk-num">
|
||||
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||
<input type="number" value={value} min={min} max={max} step={step}
|
||||
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakColor({ label, value, onChange }) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<input type="color" className="twk-swatch" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakButton({ label, onClick, secondary = false }) {
|
||||
return (
|
||||
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||
onClick={onClick}>{label}</button>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||
});
|
||||
Reference in New Issue
Block a user