diff --git a/app/src/art/hero.webp b/app/src/art/hero.webp new file mode 100644 index 0000000..4983056 Binary files /dev/null and b/app/src/art/hero.webp differ diff --git a/app/src/art/step-handover.webp b/app/src/art/step-handover.webp new file mode 100644 index 0000000..b11b368 Binary files /dev/null and b/app/src/art/step-handover.webp differ diff --git a/app/src/art/step-open.webp b/app/src/art/step-open.webp new file mode 100644 index 0000000..d5c1347 Binary files /dev/null and b/app/src/art/step-open.webp differ diff --git a/app/src/art/step-review.webp b/app/src/art/step-review.webp new file mode 100644 index 0000000..bec7b10 Binary files /dev/null and b/app/src/art/step-review.webp differ diff --git a/app/src/pages/AgentsPage.tsx b/app/src/pages/AgentsPage.tsx index f14c72b..6001a42 100644 --- a/app/src/pages/AgentsPage.tsx +++ b/app/src/pages/AgentsPage.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { CONFIG } from "../config.ts"; import { PageHeader, Segmented } from "../ui/controls.tsx"; +import { Art } from "../ui/Art.tsx"; import { Icon } from "../ui/icons.tsx"; type Client = "claude" | "codex" | "other"; @@ -43,7 +44,7 @@ export function AgentsPage() { const [client, setClient] = useState("claude"); return (
- + }> Hand the pack to the assistant you already use. It explains scripts and screens, adapts wording for operators, and asks you for what configuration can't tell it. It uses your own subscription; the toolkit adds no model or account. diff --git a/app/src/pages/Isometric.tsx b/app/src/pages/Isometric.tsx new file mode 100644 index 0000000..5232a0c --- /dev/null +++ b/app/src/pages/Isometric.tsx @@ -0,0 +1,256 @@ +import type { ReactNode } from "react"; + +/** + * Code-native isometric artwork in the OIC visual grammar (ivory bases, + * graphite parts, rust-orange connections, soft depth), drawn about documents + * rather than equipment. Decorative only: the page's HTML carries the meaning. + * + * World axes: x runs down-right, y down-left, z up. Faces are drawn by + * mapping a flat 2D drawing onto a plane, so details stay simple. + */ + +const C = Math.cos(Math.PI / 6); +const S = 0.5; +const r = (n: number) => Math.round(n * 10) / 10; +const pt = (x: number, y: number, z: number) => `${r((x - y) * C)},${r((x + y) * S - z)}`; +const poly = (pts: [number, number, number][]) => pts.map((q) => pt(...q)).join(" "); + +type Tone = "ivory" | "graphite" | "paper" | "rust" | "steel"; + +/** A solid block; only the three faces a viewer can see are drawn. */ +function Box({ x, y, z, w, d, h, tone, className }: { x: number; y: number; z: number; w: number; d: number; h: number; tone: Tone; className?: string }) { + return ( + + + + + + ); +} + +/** Draw on the horizontal plane at height z, in (x, y) world units. */ +const Top = ({ z, children }: { z: number; children: ReactNode }) => {children}; +/** Draw on the front-left face at y = Y, in (x, -z) units. */ +const FaceY = ({ y, children }: { y: number; children: ReactNode }) => {children}; +/** Draw on the front-right face at x = X, in (-y, -z) units. */ +const FaceX = ({ x, children }: { x: number; children: ReactNode }) => {children}; +function r4(n: number) { + return Math.round(n * 10000) / 10000; +} + +/** Move a whole object to a world position. */ +const At = ({ x, y, z = 0, children }: { x: number; y: number; z?: number; children: ReactNode }) => ( + {children} +); + +const H = 12; // base thickness; objects stand at z = H + +/** An ivory base plate with a soft shadow and corner fixings. */ +function Plate({ w, d }: { w: number; d: number }) { + return ( + + + + + + {[ + [8, 8], + [w - 8, 8], + [8, d - 8], + [w - 8, d - 8], + ].map(([cx, cy]) => ( + + ))} + + + ); +} + +/** The input: a zipped gateway backup. */ +export function Archive() { + return ( + + + + + + {Array.from({ length: 15 }, (_, i) => ( + + ))} + + + + + + GWBK + + + + + + + + + ); +} + +const TILES: [number, number, number, Tone][] = [ + // [col, row, height, tone]: extracted records, laid out as a little data city + [0, 0, 8, "ivory"], + [1, 0, 18, "graphite"], + [2, 0, 11, "ivory"], + [0, 1, 22, "ivory"], + [1, 1, 12, "rust"], + [2, 1, 28, "ivory"], + [0, 2, 10, "graphite"], + [1, 2, 26, "ivory"], + [2, 2, 14, "ivory"], + [0, 3, 16, "ivory"], + [1, 3, 9, "ivory"], + [2, 3, 20, "rust"], +]; + +/** The engine: a scanner reads the archive into structured records. */ +export function Extractor() { + const tiles = [...TILES].sort((a, b) => a[0] + a[1] - (b[0] + b[1])); + return ( + + + + + `M${10 + i * 10} 54V76`).join("")} /> + + + + + + + + + {tiles.map(([c, row, h, tone]) => ( + + ))} + + ); +} + +/** The output: manuals and documents, ready to hand over. */ +export function Documents() { + const sheets = [0, 1, 2, 3]; + return ( + + + + + + + + + + + + {sheets.map((i) => ( + + ))} + + + + + + + + + + + + + + + ); +} + +/** A bridge between two bases, with clips where it crosses each edge. */ +function Rail({ axis, from, to, at }: { axis: "x" | "y"; from: number; to: number; at: number }) { + const lo = Math.min(from, to); + const len = Math.abs(to - from); + return axis === "x" ? ( + + + + ) : ( + + + + ); +} + +function Clip({ x, y }: { x: number; y: number }) { + return ; +} +function ClipY({ x, y }: { x: number; y: number }) { + return ; +} + +/** A record moving along a rail; dx/dy are the world-space travel. */ +function Packet({ x, y, dx, dy, delay }: { x: number; y: number; dx: number; dy: number; delay: number }) { + const [tx, ty] = [r((dx - dy) * C), r((dx + dy) * S)]; + return ( + + + + ); +} + +// Where the three bases sit in the world. +const A = { x: 0, y: 0, w: 130, d: 130 }; +const B = { x: 155, y: 0, w: 160, d: 130 }; +const D = { x: 215, y: -175, w: 150, d: 140 }; + +/** Backup → records → documents, on three connected bases. */ +export function HeroScene() { + const railBD = B.x + 118; // world x of the rail from the records to the documents + return ( + + ); +} + +/** One base and one object, for the small step illustrations. */ +export function Vignette({ kind }: { kind: "archive" | "extract" | "documents" }) { + const box = { archive: "-118 -40 236 178", extract: "-118 -58 262 212", documents: "-126 -75 262 226" }[kind]; + const [w, d] = ({ archive: [130, 130], extract: [160, 130], documents: [150, 140] } as const)[kind]; + return ( + + ); +} diff --git a/app/src/pages/Overview.tsx b/app/src/pages/Overview.tsx index a1e69a2..371f67e 100644 --- a/app/src/pages/Overview.tsx +++ b/app/src/pages/Overview.tsx @@ -1,69 +1,119 @@ +import { useState } from "react"; import { PACKS } from "../../../lib/document/model.ts"; import { CONFIG } from "../config.ts"; import type { PageId } from "../shell/tools.ts"; +import { Art } from "../ui/Art.tsx"; import { Icon } from "../ui/icons.tsx"; -import { OpenPanel } from "../workspace/OpenPanel.tsx"; import type { WorkspaceApi } from "../workspace/useWorkspace.ts"; +import { HeroScene, Vignette } from "./Isometric.tsx"; const REPO = `https://github.com/${CONFIG.repo}`; const STEPS = [ - ["Open", "Drop a gateway backup, project export or tag export. It's read in this browser tab."], - ["Review", "Pick a pack, check each section against its source, add the customer's details and screenshots."], - ["Export", "Offline HTML, editable Word, PDF, CSV inventories, or a workspace for your AI assistant."], + ["archive", "step-open", "Open", "A gateway backup, project export or tag export, read right here in the browser."], + ["extract", "step-review", "Review", "Every view, tag, script and connection becomes evidence you can check against its file."], + ["documents", "step-handover", "Hand over", "Linked HTML, editable Word, PDF and CSV, or a workspace for your AI assistant."], ] as const; +const PROMISES = [ + ["shield", "Stays on your device"], + ["check", "Every fact traced to its file"], + ["file", "Free and open source"], +] as const; + +/** + * The front door: what the toolkit does, at a glance, and a way in. Opening + * files happens in the workspace, though a drop here still works. + */ export function Overview({ ws, navigate }: { ws: WorkspaceApi; navigate: (id: PageId) => void }) { const current = ws.document?.title; + const [over, setOver] = useState(false); const toWorkspace = () => navigate("docs.workspace"); + const sample = () => (ws.openSample(), toWorkspace()); return ( -
+
[...e.dataTransfer.types].includes("Files") && (e.preventDefault(), setOver(true))} + onDragLeave={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setOver(false)} + onDrop={(e) => { + e.preventDefault(); + setOver(false); + if (!e.dataTransfer.files.length) return; + ws.addFiles(e.dataTransfer.files); + toWorkspace(); + }} + >
-

Turn an Ignition backup into documentation you can hand over.

-

- A linked engineering reference and editable manual pack, built from the configuration itself. Every fact is traced to the file it came from, and nothing - leaves your device. -

- {current && ( - + ) : ( + + )} + - )} +
+
    + {PROMISES.map(([icon, text]) => ( +
  • + {text} +
  • + ))} +
+
+
+ } />
-
-
    - {STEPS.map(([t, d], i) => ( -
  1. - {i + 1} - {t} -

    {d}

    +
      + {STEPS.map(([scene, art, title, text], i) => ( +
    1. +
      + } /> +
      + {String(i + 1).padStart(2, "0")} + {title} +

      {text}

    2. ))}
-

Four packs from one backup

+
+

Four packs from one backup

+

+ What only the site knows, like operating steps and alarm responses, stays marked Unresolved for a person to supply. Nothing is invented. +

+
{PACKS.map((p) => (
+ {p.label}

{p.summary}

))}
-

- Frameworks leave what only the site knows, such as operating steps, alarm responses and safety information, marked Unresolved for a person to supply. -

+
+ +
+

Start with a backup you already have.

+
@@ -89,6 +139,12 @@ export function Overview({ ws, navigate }: { ws: WorkspaceApi; navigate: (id: Pa

Grindstone Systems · Open Industrial Collective. Not affiliated with or endorsed by Inductive Automation.

+ + {over && ( + + )}
); } diff --git a/app/src/pages/ToolStatusPage.tsx b/app/src/pages/ToolStatusPage.tsx index 80aac92..1b0b69e 100644 --- a/app/src/pages/ToolStatusPage.tsx +++ b/app/src/pages/ToolStatusPage.tsx @@ -2,13 +2,14 @@ import type { ReactNode } from "react"; import { CONFIG } from "../config.ts"; import type { ToolPage } from "../shell/tools.ts"; import { PageHeader, StatusPill } from "../ui/controls.tsx"; +import { Art, type ArtName } from "../ui/Art.tsx"; import { Icon } from "../ui/icons.tsx"; /** Landing page for a tool that is planned or in preview: what it will do and where it stands. */ export function ToolStatusPage({ tool, group, action }: { tool: ToolPage; group?: string; action?: ReactNode }) { return (
- }> + } art={}> {tool.summary}
diff --git a/app/src/styles.css b/app/src/styles.css index ed4614e..d91bdc7 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -20,6 +20,7 @@ --gap: #c2410c; --draft: #6d28d9; --amber: #b45309; + --rust: #c8521a; --shadow-sm: 0 1px 2px #16181b0f; --shadow: 0 1px 2px #16181b0d, 0 8px 24px #16181b0a; --shadow-lg: 0 2px 6px #16181b12, 0 24px 60px #16181b24; @@ -45,6 +46,7 @@ --gap: #f0875a; --draft: #b69cf5; --amber: #fbbf24; + --rust: #f08a50; --shadow-sm: 0 1px 2px #0006; --shadow: 0 1px 2px #0006, 0 10px 30px #0004; --shadow-lg: 0 2px 8px #0008, 0 30px 70px #0009; @@ -224,38 +226,85 @@ input[type="checkbox"][role="switch"]:checked::after { transform: translateX(14p input[type="checkbox"][role="switch"]:disabled { opacity: .45; cursor: not-allowed; } /* ------------------------------ home ------------------------------ */ -.home { max-width: 1200px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 40px); } -.home-hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: clamp(32px, 5vw, 72px); align-items: center; padding: clamp(40px, 7vh, 88px) 0 clamp(40px, 6vh, 72px); } +.home { position: relative; max-width: 1240px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 40px); } +.home-hero { display: grid; grid-template-columns: minmax(0, .82fr) minmax(0, 1.18fr); gap: clamp(24px, 4vw, 56px); align-items: center; padding: clamp(40px, 8vh, 96px) 0 clamp(32px, 6vh, 64px); } .eyebrow { margin: 0 0 12px; font-size: 12px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--brand); } -.home-copy h1 { margin: 0; max-width: 16ch; font-size: clamp(30px, 3.6vw, 44px); line-height: 1.1; letter-spacing: -0.025em; font-weight: 680; text-wrap: balance; } +.home-copy .eyebrow { color: var(--rust); } +.home-copy h1 { margin: 0; max-width: 15ch; font-size: clamp(32px, 4vw, 52px); line-height: 1.05; letter-spacing: -0.03em; font-weight: 700; text-wrap: balance; } .lede { margin: 16px 0 0; max-width: 560px; color: var(--muted); font-size: 16px; line-height: 1.55; } -.resume { - display: flex; align-items: center; gap: 18px; margin-top: 28px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; - background: var(--panel); text-align: left; box-shadow: var(--shadow-sm); transition: border-color .15s; -} -.resume:hover { border-color: var(--brand); } -.resume span { display: grid; } -.resume small { font-size: 12px; color: var(--muted); } -.resume b { font-size: 15px; } -.home-band { padding: 48px 0; border-top: 1px solid var(--line); } -.band-title { margin: 0 0 20px; font-size: 20px; font-weight: 650; } -.flow { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; counter-reset: flow; } -.flow li { display: grid; gap: 6px; align-content: start; } -.flow-n { font-size: 12px; font-weight: 600; color: var(--faint); font-variant-numeric: tabular-nums; } -.flow strong { font-size: 15px; } -.flow p { margin: 0; color: var(--muted); font-size: 13.5px; } +.home-copy .lede { margin-top: 20px; max-width: 31em; font-size: 17px; text-wrap: pretty; } +.home-cta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 32px; } +.button.big { height: 44px; padding: 0 20px; border-radius: 11px; font-size: 15px; font-weight: 600; } +.button.primary.big svg:last-child { transition: transform .15s; } +.button.primary.big:hover svg:last-child { transform: translateX(3px); } +.promises { list-style: none; display: flex; flex-wrap: wrap; gap: 8px 20px; margin: 28px 0 0; padding: 0; font-size: 13px; color: var(--muted); } +.promises li { display: inline-flex; align-items: center; gap: 7px; } +.promises svg { color: var(--rust); } +.home-art { min-width: 0; margin-right: clamp(-40px, -2vw, 0px); } +.home-art .iso { display: block; width: 100%; height: auto; overflow: visible; } +.home-band { padding: 56px 0; border-top: 1px solid var(--line); } +.band-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 32px; margin-bottom: 20px; } +.band-title { margin: 0; font-size: 22px; font-weight: 680; } +.band-note { margin: 0; max-width: 520px; font-size: 13.5px; color: var(--muted); } +.steps { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(20px, 3vw, 40px); } +.steps li { display: grid; gap: 6px; align-content: start; } +.step-art { display: grid; place-items: center; height: 204px; margin-bottom: 14px; border-radius: 16px; background: var(--canvas); } +.step-art .iso { width: auto; height: 164px; max-width: 90%; } +.step-n { font-size: 12px; font-weight: 650; color: var(--rust); font-variant-numeric: tabular-nums; letter-spacing: .04em; } +.steps strong { font-size: 17px; letter-spacing: -0.01em; } +.steps p { margin: 0; color: var(--muted); font-size: 14px; max-width: 36ch; text-wrap: pretty; } .pack-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; } .pack-card { display: grid; gap: 6px; align-content: start; padding: 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); } .pack-card strong { font-size: 15px; } +.pack-art { width: 88px; height: 88px; object-fit: contain; margin: -4px 0 6px -6px; } .pack-card p { margin: 0; font-size: 13px; color: var(--muted); } .pack-kind { justify-self: start; font-size: 10.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--brand); } .pack-kind.fw { color: var(--muted); } -.band-note { margin: 16px 0 0; max-width: 720px; font-size: 13px; color: var(--muted); } +.home-close { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px 32px; margin: 8px 0 56px; padding: 32px clamp(20px, 4vw, 40px); border-radius: 18px; background: var(--ink); color: var(--bg); } +.home-close h2 { margin: 0; font-size: clamp(20px, 2.4vw, 26px); font-weight: 680; } +.home-close .button.primary { background: var(--bg); border-color: var(--bg); color: var(--ink); } .home-foot { display: grid; gap: 12px; padding: 28px 0 40px; border-top: 1px solid var(--line); font-size: 12.5px; color: var(--muted); } .home-foot p { margin: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } .home-foot nav { display: flex; flex-wrap: wrap; gap: 6px 18px; } .home-foot a { color: var(--muted); text-decoration: none; font-weight: 550; } .home-foot a:hover { color: var(--ink); } +.home-drop { + position: fixed; inset: 64px 16px 16px; z-index: 30; display: flex; align-items: center; justify-content: center; gap: 10px; pointer-events: none; + border: 2px dashed var(--brand); border-radius: 18px; background: color-mix(in srgb, var(--bg) 85%, transparent); color: var(--brand); font-size: 17px; font-weight: 650; +} + +/* Isometric artwork (pages/Isometric.tsx): OIC's ivory, graphite and rust. */ +.iso .ivory .t { fill: #f1ebdf; } .iso .ivory .l { fill: #e0d7c6; } .iso .ivory .r { fill: #cbc1ae; } +.iso .paper .t { fill: #fdfbf6; } .iso .paper .l { fill: #ebe5d8; } .iso .paper .r { fill: #d8d0c0; } +.iso .graphite .t { fill: #4a4e55; } .iso .graphite .l { fill: #33363b; } .iso .graphite .r { fill: #25272b; } +.iso .rust .t { fill: #f07a3a; } .iso .rust .l { fill: #d65a1d; } .iso .rust .r { fill: #b64813; } +.iso polygon { stroke: #00000014; stroke-width: .5; stroke-linejoin: round; } +.iso-shadow { fill: #3a2e1e; opacity: .14; filter: blur(7px); } +.iso-rim { fill: none; stroke: #fffaf0; stroke-width: 1.4; opacity: .8; } +.iso-hole { fill: #cfc5b1; stroke: #b8ad97; stroke-width: .6; } +.iso-track { fill: #222428; } +.iso-tooth { fill: #a3a7ad; } +.iso-accent { fill: #e8642a; } +.iso-label { fill: #efe8da; } +.iso-label-text { font: 700 9px var(--mono); fill: #33363b; letter-spacing: .08em; } +.iso-label-line, .iso-text { fill: none; stroke: #9a9282; stroke-width: 1.6; stroke-linecap: round; } +.iso-vent { fill: none; stroke: #3a3d43; stroke-width: 1.6; stroke-linecap: round; } +.iso-roller { fill: none; stroke: #4c5057; stroke-width: 1; } +.iso-ink { fill: #3c3f45; } +.iso-grid { fill: none; stroke: #c3b9a6; stroke-width: .7; } +.iso-seal { fill: #e8642a; } +.iso-check { fill: none; stroke: #fff; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; } +.iso-beam { fill: #f07a3a; opacity: .22; animation: iso-scan 2.4s ease-in-out infinite; } +.iso-packet { opacity: 0; animation: iso-move 3.6s linear infinite; } +@keyframes iso-move { + 0% { transform: translate(0, 0); opacity: 0; } + 12%, 80% { opacity: 1; } + 100% { transform: translate(var(--tx), var(--ty)); opacity: 0; } +} +@keyframes iso-scan { 50% { opacity: .42; } } +:root[data-theme="dark"] .iso-shadow { fill: #000; opacity: .5; } +:root[data-theme="dark"] .step-art { background: var(--panel); } +@media (prefers-reduced-motion: reduce) { .iso-flow { display: none; } } .byline { color: var(--faint); } /* the drop area (home and empty workspace) */ @@ -516,6 +565,11 @@ details.code pre { margin: 8px 0 0; max-height: 320px; overflow: auto; padding: .page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; } .page-head h1 { margin: 0; font-size: 30px; line-height: 1.15; letter-spacing: -0.025em; } .page-head .eyebrow { color: var(--muted); } +.page-art { flex: none; width: clamp(160px, 24vw, 280px); margin: -16px 0 -24px; } +.art { display: block; max-width: 100%; height: auto; user-select: none; } +.home-art .art { width: 100%; } +.step-art .art { height: 184px; width: auto; max-width: 90%; object-fit: contain; } +.ws-empty-art { width: 220px; margin: 0 auto 8px; } .page-actions { flex: none; padding-top: 4px; } .page-actions .pill { font-size: 12px; padding: 6px 10px; } .text-link { display: inline-flex; align-items: center; gap: 6px; color: var(--ink); font-weight: 600; font-size: 13px; } @@ -555,13 +609,15 @@ details.code pre { margin: 8px 0 0; max-height: 320px; overflow: auto; padding: .ws-bar > .segmented { grid-row: 2; grid-column: 1 / -1; display: flex; } .ws-bar-end .button:not(.primary) .lbl { display: none; } .src-text b { max-width: 150px; } - .flow { grid-template-columns: minmax(0, 1fr); gap: 20px; } + .steps { grid-template-columns: minmax(0, 1fr); gap: 32px; } + .home-art { margin-right: 0; } .open-alt { flex-direction: column; gap: 4px; } .dot-sep { display: none; } .formats { grid-template-columns: minmax(0, 1fr); gap: 2px; } .cov-summary { grid-template-columns: minmax(0, 1fr); } .status-grid { grid-template-columns: minmax(0, 1fr); } .page-head { flex-direction: column; gap: 12px; } + .page-art { display: none; } .preview { width: calc(100% - 16px); height: calc(100% - 16px); margin: 8px; } .formats-list li { grid-template-columns: auto minmax(0, 1fr); } .formats-list .button { grid-column: 1 / -1; } diff --git a/app/src/ui/Art.tsx b/app/src/ui/Art.tsx new file mode 100644 index 0000000..6684473 --- /dev/null +++ b/app/src/ui/Art.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; + +/** + * Generated illustrations (docs/ART.md). Each lives in app/src/art/.webp + * and is bundled with the app, so it loads from our own origin and works + * offline. Until a file exists, the slot shows its fallback, or nothing. + */ +const FILES = import.meta.glob("../art/*.webp", { eager: true, import: "default" }); +const URLS = new Map(Object.entries(FILES).map(([path, url]) => [path.slice("../art/".length, -".webp".length), url])); + +export type ArtName = + | "hero" + | "step-open" + | "step-review" + | "step-handover" + | "pack-engineering-reference" + | "pack-operator-manual" + | "pack-maintenance-guide" + | "pack-complete-handoff" + | "workspace-empty" + | "agents" + | "platforms"; + +export const hasArt = (name: ArtName) => URLS.has(name); + +/** Decorative: the surrounding HTML carries the meaning, so alt is empty. */ +export function Art({ name, className, eager, fallback = null }: { name: ArtName; className?: string; eager?: boolean; fallback?: ReactNode }) { + const src = URLS.get(name); + if (!src) return <>{fallback}; + return ; +} diff --git a/app/src/ui/controls.tsx b/app/src/ui/controls.tsx index 8c396cc..d29b0f2 100644 --- a/app/src/ui/controls.tsx +++ b/app/src/ui/controls.tsx @@ -25,7 +25,7 @@ export function Segmented({ } /** Title block shared by the content pages. */ -export function PageHeader({ eyebrow, title, children, actions }: { eyebrow?: string; title: string; children?: ReactNode; actions?: ReactNode }) { +export function PageHeader({ eyebrow, title, children, actions, art }: { eyebrow?: string; title: string; children?: ReactNode; actions?: ReactNode; art?: ReactNode }) { return (
@@ -34,6 +34,7 @@ export function PageHeader({ eyebrow, title, children, actions }: { eyebrow?: st {children &&
{children}
}
{actions &&
{actions}
} + {art &&
{art}
}
); } diff --git a/app/src/workspace/WorkspacePage.tsx b/app/src/workspace/WorkspacePage.tsx index 2406c39..fa8606c 100644 --- a/app/src/workspace/WorkspacePage.tsx +++ b/app/src/workspace/WorkspacePage.tsx @@ -4,6 +4,7 @@ import { slug } from "../../../lib/render/escape.ts"; import { renderHtml } from "../../../lib/render/html.ts"; import { unresolved } from "../../../lib/resolve.ts"; import { dataUris } from "../../../lib/workspace.ts"; +import { Art } from "../ui/Art.tsx"; import { Popover, Segmented } from "../ui/controls.tsx"; import { Icon } from "../ui/icons.tsx"; import { Coverage } from "./Coverage.tsx"; @@ -167,6 +168,7 @@ export function WorkspacePage({ return (
e.preventDefault()} onDrop={onDrop}>
+

Open a backup or project

Your files are read in this tab and never uploaded. Nothing is kept after you close it.

diff --git a/docs/ART.md b/docs/ART.md new file mode 100644 index 0000000..1ee0b52 --- /dev/null +++ b/docs/ART.md @@ -0,0 +1,95 @@ +# Illustrations + +Documentation Toolkit uses the Open Industrial Collective's dimensional visual grammar, drawn about documents and data rather than plant equipment. Generated art is decorative: every image has empty alt text, and the page's HTML carries the meaning. Images never show the product's real screens, and they never contain words, logos, brands, badges or certification marks. + +## Visual grammar + +- Three-quarter isometric camera, the same angle throughout, lit from the upper left. +- Ivory bases with rounded edges, small screw holes and a fine speckled texture. +- Graphite cases and panels, brushed steel details. +- Rust-orange (about `#e8642a`) for connections, rails, status lights and one accent per object. +- Paper in warm white, with grey ruled lines and simple bar or table marks in place of text. +- Soft contact shadows, gentle depth, tactile matte materials. No people, faces or robots. +- Transparent background, with the object centred and a little margin on every side. + +## How to add one + +1. Generate the image with the prompt below. Attach `docs/sources/art/style-oic-workbench.png` as the style reference. For the hero and the three steps, also attach the matching `layout-*.png` so the composition matches the page. (`docs/sources/` stays local.) +2. Export as PNG with a transparent background, at least the source size listed. +3. Convert it into the app (trim, resize, WebP with alpha): + + ```sh + magick in.png -trim +repage -resize 1600x\> png:- | cwebp -q 82 -alpha_q 90 -exact -o app/src/art/hero.webp -- - + ``` + +The app picks up any `app/src/art/.webp` automatically (see `app/src/ui/Art.tsx`). Until the file exists, the hero and steps show the code-drawn scene from `app/src/pages/Isometric.tsx` and the other slots show nothing. Keep each file under about 150 KB. + +## Assets + +In the app so far: `hero`, `step-open`, `step-review` and `step-handover`. + +| File | Where it appears | Shape | Source size | Web width | +| --- | --- | --- | --- | --- | +| `hero.webp` | Home hero | 2:1 | 2400×1200 | 1600 | +| `step-open.webp` | Home, step 1 | 4:3 | 1200×900 | 640 | +| `step-review.webp` | Home, step 2 | 4:3 | 1200×900 | 640 | +| `step-handover.webp` | Home, step 3 | 4:3 | 1200×900 | 640 | +| `pack-engineering-reference.webp` | Home, pack card | 1:1 | 1024×1024 | 256 | +| `pack-operator-manual.webp` | Home, pack card | 1:1 | 1024×1024 | 256 | +| `pack-maintenance-guide.webp` | Home, pack card | 1:1 | 1024×1024 | 256 | +| `pack-complete-handoff.webp` | Home, pack card | 1:1 | 1024×1024 | 256 | +| `workspace-empty.webp` | Workspace, before files are opened | 4:3 | 1200×900 | 480 | +| `agents.webp` | AI agents page header | 4:3 | 1200×900 | 640 | +| `platforms.webp` | More platforms page header | 4:3 | 1200×900 | 640 | + +## Prompts + +Start every prompt with the shared style, then add the subject. + +**Shared style** + +> Crisp 3D isometric product illustration, three-quarter view from the upper left, on a fully transparent background. Modular tabletop pieces on ivory bases with rounded edges, small screw holes and a fine speckled matte texture. Graphite cases, brushed steel details, warm white paper with grey ruled lines. Rust-orange is the only accent colour, used for connecting rails, cable clips, status lights and one highlight per object. Soft contact shadows, gentle depth, clean studio lighting, tactile matte materials, high detail, sharp edges. No text, letters, numbers, logos, brands, badges, watermarks, people, faces or robots. + +**hero** + +> Three connected ivory bases joined by rust-orange rails with small orange data cubes travelling along them, left to right. Left base: a closed graphite archive case with a zipper across its lid and a blank paper label on its front, like a backup cartridge. Centre base, nearest the viewer: a graphite scanning gantry over a short conveyor, a sheet of paper passing beneath a soft orange scan light, and beyond it a neat grid of small ivory and graphite blocks of different heights standing for extracted records, two of them orange. Right base: an upright graphite binder and a standing ivory manual behind a fanned stack of crisp documents with ruled lines, a small table and an orange check seal, plus a flat spreadsheet sheet with an orange header row. Wide 2:1 composition matching the attached layout. + +**step-open** + +> One ivory base holding the graphite archive case with a zipper across its lid and a blank paper label, an orange status light on its side. A short rust-orange rail leaves the base toward the right edge. Matches the attached layout. + +**step-review** + +> One ivory base holding a graphite scanning gantry over a short conveyor, a sheet of paper under a soft orange scan light, and a grid of small ivory and graphite record blocks of different heights. A magnifying glass with an orange handle leans against the blocks. Matches the attached layout. + +**step-handover** + +> One ivory base holding an upright graphite binder, a standing ivory manual and a fanned stack of documents with ruled lines, a small table and an orange check seal, plus a flat spreadsheet sheet with an orange header row. Matches the attached layout. + +**pack-engineering-reference** + +> A single small ivory base holding a graphite binder lying open, its pages showing a simple connected block diagram drawn in grey lines with orange nodes. Compact, centred, square. + +**pack-operator-manual** + +> A single small ivory base holding a graphite tablet-style operator panel standing upright with simple grey screen shapes and one orange status light, beside a closed ivory manual. Compact, centred, square. + +**pack-maintenance-guide** + +> A single small ivory base holding a closed ivory guide with an orange bookmark ribbon, a steel wrench lying across it and a small graphite device with an orange indicator. Compact, centred, square. + +**pack-complete-handoff** + +> A single small ivory base holding a neat bundle of three binders, graphite, ivory and steel, tied with a rust-orange strap, ready to hand over. Compact, centred, square. + +**workspace-empty** + +> A single ivory base with a shallow graphite intake tray, its open mouth facing the viewer, and a closed graphite archive case hovering just above it about to drop in, with a few small orange data cubes beneath. Calm, inviting, plenty of empty space. + +**agents** + +> Two ivory bases joined by a rust-orange rail. Left: a fanned stack of documents with ruled lines and an orange check seal. Right: a compact graphite console with a blank dark screen showing simple grey lines and an orange cursor block, and a small ivory speech-bubble shaped tile standing beside it. Small orange data cubes move between them. + +**platforms** + +> One central ivory base holding a graphite processing module, with three different input cartridges, graphite, steel and ivory, each on its own small base and each joined to the centre by a rust-orange rail. The cartridges differ in shape to suggest different platforms.