From 8e00e2d7612e6122105aaa569a2fd2a5c4de0cef Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 14 Sep 2026 00:21:06 +0200 Subject: [PATCH 1/4] Add Petricon to Petrinaut --- .changeset/petrinaut-experimental-icons.md | 5 + apps/petrinaut-website/README.md | 10 + apps/petrinaut-website/src/petricon-page.tsx | 178 ++++ .../src/petricon-page/glyph-demo.tsx | 88 ++ .../src/petricon-page/petricon.css | 792 ++++++++++++++++++ .../src/petricon-page/playground.tsx | 416 +++++++++ .../src/petricon-page/studies.tsx | 81 ++ .../petrinaut-website/src/routes/petricon.tsx | 7 + libs/@hashintel/petrinaut/README.md | 188 +++++ .../petrinaut/docs/visual-settings.md | 71 ++ libs/@hashintel/petrinaut/src/panda-preset.ts | 48 ++ .../src/react/state/user-settings-context.ts | 4 + .../react/state/user-settings-provider.tsx | 5 + .../spreadsheet/gutter-menu.tsx | 14 +- .../src/ui/components/ai-assistant-icon.tsx | 103 ++- .../src/ui/experimental-icons.stories.tsx | 750 +++++++++++++++++ .../src/ui/experimental-icons.test.tsx | 598 +++++++++++++ .../petrinaut/src/ui/experimental-icons.tsx | 704 ++++++++++++++++ .../experimental-icons/add-node-geometry.tsx | 123 +++ .../additional-geometry.tsx | 268 ++++++ .../exploration-geometry.tsx | 204 +++++ .../src/ui/experimental-icons/interaction.tsx | 129 +++ .../experimental-icons/modeling-geometry.tsx | 219 +++++ .../src/ui/experimental-icons/motion.tsx | 341 ++++++++ .../motion/action-motion.ts | 199 +++++ .../experimental-icons/parameter-geometry.tsx | 79 ++ .../experimental-icons/settings-geometry.tsx | 27 + .../ui/experimental-icons/shapes-geometry.tsx | 87 ++ .../shared/cube-projection.test.ts | 41 + .../shared/cube-projection.ts | 88 ++ .../shared/icon-motion-layer.tsx | 17 + .../shared/petricon-hints.ts | 188 +++++ .../experimental-icons/sidebar-geometry.tsx | 74 ++ .../simulation-geometry.tsx | 204 +++++ .../ui/experimental-icons/state-geometry.tsx | 160 ++++ .../experimental-icons/utility-geometry.tsx | 204 +++++ libs/@hashintel/petrinaut/src/ui/index.ts | 173 ++++ libs/@hashintel/petrinaut/src/ui/petricon.ts | 278 ++++++ .../BottomBar/diagnostics-indicator.test.tsx | 89 ++ .../BottomBar/diagnostics-indicator.tsx | 114 ++- .../components/BottomBar/edition-tools.tsx | 33 +- .../BottomBar/playback-settings-menu.tsx | 9 +- .../BottomBar/simulation-controls.tsx | 9 +- .../components/BottomBar/toolbar-button.tsx | 5 +- .../Editor/components/TopBar/top-bar.tsx | 19 +- .../components/ai-voice-mode-button.tsx | 43 +- .../src/ui/views/Editor/editor-view.tsx | 10 +- .../create-experiment-drawer.test.tsx | 1 + .../voice-dock/microphone-icon.tsx | 76 +- .../voice-input-provenance.tsx | 52 +- .../src/ui/views/Notebook/notebook-cell.tsx | 15 +- .../SDCPN/components/viewport-controls.tsx | 15 +- .../components/viewport-settings-dialog.tsx | 20 + .../content/diagrams/petricon-rendering.d2 | 15 + .../petrinaut-arch-docs/content/index.mdx | 2 + .../content/ui/petricon.mdx | 229 +++++ 56 files changed, 7787 insertions(+), 134 deletions(-) create mode 100644 .changeset/petrinaut-experimental-icons.md create mode 100644 apps/petrinaut-website/src/petricon-page.tsx create mode 100644 apps/petrinaut-website/src/petricon-page/glyph-demo.tsx create mode 100644 apps/petrinaut-website/src/petricon-page/petricon.css create mode 100644 apps/petrinaut-website/src/petricon-page/playground.tsx create mode 100644 apps/petrinaut-website/src/petricon-page/studies.tsx create mode 100644 apps/petrinaut-website/src/routes/petricon.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons.stories.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/add-node-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/additional-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/exploration-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/interaction.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/modeling-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/motion.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/motion/action-motion.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/parameter-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/settings-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/shapes-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/icon-motion-layer.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/petricon-hints.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/sidebar-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/simulation-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/state-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/experimental-icons/utility-geometry.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/petricon.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/diagnostics-indicator.test.tsx create mode 100644 libs/@local/petrinaut-arch-docs/content/diagrams/petricon-rendering.d2 create mode 100644 libs/@local/petrinaut-arch-docs/content/ui/petricon.mdx diff --git a/.changeset/petrinaut-experimental-icons.md b/.changeset/petrinaut-experimental-icons.md new file mode 100644 index 00000000000..a06a29bb7a9 --- /dev/null +++ b/.changeset/petrinaut-experimental-icons.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add Petricon, an optional animated SVG icon library across the editor with adjustable stroke weight, subtle action hints, state transitions, configurable effects, and automatic fallback for icons outside the pack. diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index d4cac073c99..26d685263b5 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -24,6 +24,16 @@ The dev server runs at [http://localhost:5173](http://localhost:5173). A plugin In production, the functions in the `api` folder are automatically deployed as Vercel Functions. +## Petricon + +`/petricon` is the interactive icon collection. Browse the symbols, adjust their +weight and timing, compare states, and copy React examples. Modeling studies offer +three alternatives each for equations, parameters, variables, token types, and +subnets, with action and progressive drawing playback. The inspector supports +drawing order and manual progress. Math, code, and AI studies offer paired +alternatives. The page uses the same Petricon +renderer as the editor. + ## Starting a new net `/new` creates an empty net in local storage and redirects to the editable demo, diff --git a/apps/petrinaut-website/src/petricon-page.tsx b/apps/petrinaut-website/src/petricon-page.tsx new file mode 100644 index 00000000000..84fa9cfec32 --- /dev/null +++ b/apps/petrinaut-website/src/petricon-page.tsx @@ -0,0 +1,178 @@ +import { PetriconProvider, petriconCatalog } from "@hashintel/petrinaut/ui"; + +import { GlyphDemo } from "./petricon-page/glyph-demo"; +import { Playground } from "./petricon-page/playground"; +import { Studies } from "./petricon-page/studies"; +import "./petricon-page/petricon.css"; + +export const PetriconPage = () => ( +
+ Petricon — Icons with intent + + + Skip to icons + +
+
+ + + +
+
+
+
+ + A SYMBOL LIBRARY BY PETRINAUT + + + SVG / VARIABLE WEIGHT / MOTION + +
+

+ Petricon. +

+
+

+ Small forms. +
+ Clear intent. +

+
+

+ Icons drawn to belong together. +
+ Motion that tells you what comes next. +

+ + Play with the collection + +
+ + {petriconCatalog.length} + ORIGINAL SYMBOLS + +
+
+ +
+ + + + +
+
+
+

+ 01 / FORMOne grid. A continuous range of weights. +

+

+ 02 / MOTIONA hint on hover. A response on press. +

+

+ 03 / STATEThe same parts, finding a new position. +

+
+
+
+
+ 01 — THE COLLECTION +

+ Make yourself +
+ understood. +

+
+

+ Find a symbol. Tune its weight. +
+ Try its motion and change its state. +

+
+ +
+
+
+
+ 02 — NEW STUDIES +

+ More ways +
+ to express it. +

+
+

+ Models, math, code, and artificial intelligence. +
+ Multiple directions. Each with its own motion. +

+
+ +
+
+
+ 03 — IN YOUR INTERFACE +

+ A little code. +
A lot of character. +

+

+ Use a name, pass a state, choose a weight. +
+ Petricon handles the motion. +

+

+ Available in @hashintel/petrinaut/ui.
+ Respects reduced motion. Keyboard friendly. +

+ + Open Petrinaut + +
+
+ REACT / TYPESCRIPT +
+              {`import { Petricon, PetriconProvider }\n  from "@hashintel/petrinaut/ui";\nimport "@hashintel/petrinaut/styles.css";\n\n\n  \n`}
+            
+
+
+
+ +
+
+); diff --git a/apps/petrinaut-website/src/petricon-page/glyph-demo.tsx b/apps/petrinaut-website/src/petricon-page/glyph-demo.tsx new file mode 100644 index 00000000000..b9c97609ee3 --- /dev/null +++ b/apps/petrinaut-website/src/petricon-page/glyph-demo.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; + +import { + Petricon, + type PetriconName, + type PetriconProps, + type PetriconEffect, +} from "@hashintel/petrinaut/ui"; + +export const getGlyphState = ( + name: PetriconName, + step: number, +): Partial< + Pick< + PetriconProps, + "status" | "collapsed" | "open" | "muted" | "playing" | "selected" + > +> => { + const active = step % 2 === 1; + switch (name) { + case "diagnostics": + return { + status: step % 3 === 0 ? "valid" : step % 3 === 1 ? "error" : "warning", + }; + case "sidebar": + return { collapsed: active }; + case "settings": + case "menu": + return { open: active }; + case "microphone": + return { muted: active }; + case "playback": + return { playing: active }; + default: + return { selected: active }; + } +}; + +export const statefulGlyphs = new Set([ + "diagnostics", + "sidebar", + "settings", + "menu", + "microphone", + "playback", + "shapes", + "flask", + "layer", + "addPlace", + "addTransition", +]); + +export const GlyphDemo = ({ + name, + label, + size = 64, + effect = "action", +}: { + name: PetriconName; + label: string; + size?: number; + effect?: PetriconEffect; +}) => { + const [step, setStep] = useState(0); + return ( + + ); +}; diff --git a/apps/petrinaut-website/src/petricon-page/petricon.css b/apps/petrinaut-website/src/petricon-page/petricon.css new file mode 100644 index 00000000000..c0ecb0f36a4 --- /dev/null +++ b/apps/petrinaut-website/src/petricon-page/petricon.css @@ -0,0 +1,792 @@ +.petrinaut-root.petricon-page { + --petricon-paper: #f7f6f2; + --petricon-ink: #20201e; + --petricon-muted: #73736a; + --petricon-rule: #d8d7cf; + --petricon-red: #e43a25; + /* Petrinaut's bundled base styles have specificity from flattened layers. */ + background: var(--petricon-paper) !important; + color: var(--petricon-ink); + height: 100dvh; + overflow: auto; + scroll-behavior: smooth; + font-family: "Inter Variable", "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.45; + accent-color: var(--petricon-red); +} +.petricon-page *, +.petricon-page *::before, +.petricon-page *::after { + box-sizing: border-box; +} +.petricon-page :is(h1, h2, h3, p, pre) { + margin: 0; +} +.petricon-page a { + color: inherit; + text-decoration: none; +} +.petricon-page button { + font: inherit; + color: inherit; + line-height: 1.4; + cursor: pointer; + border: 0; + background: transparent; +} +.petricon-page :is(button, a, input, select):focus-visible { + outline: 2px solid var(--petricon-red); + outline-offset: 4px; +} +.petricon-page a:hover { + color: var(--petricon-red); +} +.petricon-sheet { + max-width: 1600px; + margin: auto; + padding: 0 56px; +} +.petricon-nav { + height: 88px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--petricon-ink); +} +.petricon-home { + display: inline-flex; + align-items: center; + gap: 14px; + font-weight: 650; + font-size: 11px; + letter-spacing: 0.04em; +} +.petricon-nav-slash { + color: #a8a79f; +} +.petricon-mark { + width: 10px; + height: 10px; + background: var(--petricon-red); + display: inline-block; +} +.petricon-nav nav { + display: flex; + gap: 28px; + font-size: 12px; +} +.petricon-hero { + padding-top: 28px; +} +.petricon-kicker { + font-family: "JetBrains Mono", monospace; + font-size: 10px; + font-weight: 400; + letter-spacing: 0.04em; + color: var(--petricon-muted); +} +.petricon-hero-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.petricon-hero h1 { + font-size: clamp(100px, 15vw, 218px); + font-weight: 550; + line-height: 1.16; + letter-spacing: -0.078em; + margin: 12px 0 20px -10px; +} +.petricon-hero h1 > span { + color: var(--petricon-red); +} +.petricon-hero-bottom { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 32px; + align-items: end; + padding: 0 0 36px; +} +.petricon-hero-bottom h2 { + font-size: clamp(25px, 3vw, 42px); + font-weight: 450; + line-height: 1.08; + letter-spacing: -0.04em; +} +.petricon-hero-bottom p { + font-size: 14px; + color: var(--petricon-muted); + line-height: 1.65; +} +.petricon-cta { + display: inline-flex; + gap: 38px; + margin-top: 18px; + font-size: 12px; + border-bottom: 1px solid var(--petricon-ink); + padding-bottom: 6px; +} +.petricon-edition { + font-size: 50px; + font-weight: 400; + line-height: 1; + letter-spacing: -0.06em; + text-align: right; +} +.petricon-edition small { + display: block; + font-family: monospace; + font-size: 9px; + letter-spacing: 0.025em; + margin-top: 8px; + color: var(--petricon-muted); +} +.petricon-hero-demos { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-block: 1px solid var(--petricon-ink); +} +.petricon-page .petricon-glyph-demo { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + width: 100%; + min-height: 186px; + padding: 32px 22px 14px; + transition: + background 0.2s, + color 0.2s; +} +.petricon-hero-demos > button + button { + border-left: 1px solid var(--petricon-rule); +} +.petricon-glyph-demo > span { + width: 100%; + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 11px; + text-align: left; +} +.petricon-glyph-demo > span > span { + color: var(--petricon-muted); +} +.petricon-page .petricon-glyph-demo:hover { + background: #eeede7; + color: var(--petricon-red); +} +.petricon-principles { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 36px; + padding-block: 24px; + border-bottom: 1px solid var(--petricon-rule); +} +.petricon-principles p { + font-size: 11px; +} +.petricon-principles span { + display: block; + font-family: monospace; + font-size: 9px; + color: var(--petricon-muted); + margin-bottom: 8px; +} +.petricon-section { + padding: 88px 0 0; + scroll-margin-top: 24px; +} +.petricon-section-heading { + display: flex; + justify-content: space-between; + align-items: end; + gap: 40px; + padding-bottom: 32px; +} +.petricon-section h2 { + font-size: clamp(32px, 4.4vw, 58px); + font-weight: 450; + line-height: 1.05; + letter-spacing: -0.055em; + margin-top: 18px; +} +.petricon-section-heading > p { + color: var(--petricon-muted); + font-size: 12px; + line-height: 1.7; + min-width: 280px; +} +.petricon-collection-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 28px; + padding: 18px 0; + border-block: 1px solid var(--petricon-ink); +} +.petricon-search { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + max-width: 450px; +} +.petricon-search input { + background: transparent; + border: 0; + color: var(--petricon-ink); + font: inherit; + padding: 3px; + min-width: 0; + width: 100%; +} +.petricon-search > span { + font-family: monospace; + font-size: 11px; + color: var(--petricon-muted); +} +.petricon-motion-toggle { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; +} +.petricon-filters { + display: flex; + gap: 22px; + flex-wrap: wrap; + padding: 18px 0; +} +.petricon-page .petricon-filters button { + padding: 0 0 6px; + border-bottom: 1px solid transparent; + font-size: 11px; + color: var(--petricon-muted); +} +.petricon-page .petricon-filters button[aria-pressed="true"] { + color: var(--petricon-ink); + border-color: var(--petricon-red); +} +.petricon-workbench { + display: grid; + grid-template-columns: minmax(0, 1fr) 310px; + align-items: start; + gap: 32px; +} +.petricon-icon-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + border-left: 1px solid var(--petricon-rule); + border-top: 1px solid var(--petricon-rule); +} +.petricon-page .petricon-icon-tile { + position: relative; + min-width: 0; + height: 106px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 17px; + padding: 15px 5px 10px; + border-bottom: 1px solid var(--petricon-rule); + border-right: 1px solid var(--petricon-rule); + transition: + background 0.16s, + color 0.16s; +} +.petricon-icon-tile > span:first-of-type { + font-size: 9px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.petricon-page .petricon-icon-tile:hover { + background: #eeede7; +} +.petricon-page .petricon-icon-tile[aria-pressed="true"] { + color: var(--petricon-red); + background: #f0e9e2; + box-shadow: inset 0 0 0 1px var(--petricon-red); +} +.petricon-study-dot { + position: absolute; + top: 9px; + right: 9px; + height: 3px; + width: 3px; + background: var(--petricon-red); +} +.petricon-page .petricon-show-all { + display: flex; + justify-content: space-between; + width: 100%; + font-size: 11px; + padding: 18px 0; + border-bottom: 1px solid var(--petricon-ink); +} +.petricon-inspector { + position: sticky; + top: 24px; + border: 1px solid var(--petricon-ink); + padding: 20px; + background: var(--petricon-paper); +} +.petricon-inspector-heading { + display: flex; + justify-content: space-between; +} +.petricon-page .petricon-specimen { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 182px; + margin: 12px 0; +} +.petricon-specimen > svg { + position: relative; + z-index: 1; +} +.petricon-crosshair { + position: absolute; + inset: 15px 36px 26px; + background: + linear-gradient( + 90deg, + transparent calc(50% - 0.5px), + var(--petricon-rule) 50%, + transparent calc(50% + 0.5px) + ), + linear-gradient( + transparent calc(50% - 0.5px), + var(--petricon-rule) 50%, + transparent calc(50% + 0.5px) + ); + border: 1px solid var(--petricon-rule); + opacity: 0.6; +} +.petricon-specimen-hint { + position: absolute; + bottom: 0; + font-size: 9px; + color: var(--petricon-muted); +} +.petricon-inspector-title { + display: flex; + justify-content: space-between; + align-items: center; + padding-block: 10px 18px; + gap: 10px; +} +.petricon-inspector-title h3 { + font-size: 18px; + letter-spacing: -0.035em; + font-weight: 500; +} +.petricon-inspector-title > span { + font-size: 10px; + color: var(--petricon-muted); +} +.petricon-sliders { + display: grid; + gap: 12px; + border-top: 1px solid var(--petricon-rule); + padding-top: 18px; +} +.petricon-sliders label { + display: block; +} +.petricon-sliders label > span { + display: flex; + justify-content: space-between; + font-size: 11px; + margin-bottom: 8px; +} +.petricon-sliders output { + font-family: monospace; + color: var(--petricon-muted); +} +.petricon-sliders input { + display: block; + width: 100%; + height: 12px; + cursor: ew-resize; +} +.petricon-selects { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 18px; +} +.petricon-selects label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 10px; + color: var(--petricon-muted); +} +.petricon-selects select { + width: 100%; + border: 0; + border-bottom: 1px solid var(--petricon-rule); + background: transparent; + color: var(--petricon-ink); + font: inherit; + font-size: 12px; + padding: 5px 0; +} +.petricon-inspector-actions { + display: flex; + gap: 16px; + margin: 20px 0 16px; +} +.petricon-page .petricon-inspector-actions button { + font-size: 11px; + flex: 1; + display: flex; + justify-content: space-between; + gap: 10px; + border-bottom: 1px solid var(--petricon-ink); + padding-bottom: 7px; +} +.petricon-code { + border-top: 1px solid var(--petricon-rule); + padding: 16px 0 8px; + font-family: "JetBrains Mono", monospace; + font-size: 10px !important; + line-height: 1.8; + overflow: auto; +} +.petricon-code code, +.petricon-usage-code code { + font: inherit !important; +} +.petricon-page .petricon-copy { + display: flex; + justify-content: space-between; + width: 100%; + font-size: 10px; + color: var(--petricon-muted); + padding-block: 4px; + text-align: left; +} +.petricon-empty { + padding: 40px 0; + color: var(--petricon-muted); +} +.petricon-study-tabs { + display: flex; + border-block: 1px solid var(--petricon-ink); + margin-bottom: 24px; +} +.petricon-page .petricon-study-tabs button { + flex: 1; + display: flex; + justify-content: space-between; + padding: 14px 20px; + font-size: 13px; + border-right: 1px solid var(--petricon-rule); +} +.petricon-page .petricon-study-tabs button:last-child { + border-right: 0; +} +.petricon-page .petricon-study-tabs button[aria-pressed="true"] { + color: var(--petricon-paper); + background: var(--petricon-ink); +} +.petricon-studies-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px 40px; +} +.petricon-study { + border-bottom: 1px solid var(--petricon-rule); +} +.petricon-study-heading { + display: flex; + align-items: center; + justify-content: space-between; +} +.petricon-study-heading h3 { + font-size: 16px; + font-weight: 500; + letter-spacing: -0.025em; +} +.petricon-study-pair { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + margin-top: 20px; +} +.petricon-study-motion { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 32px; + font-size: 12px; +} +.petricon-study-motion > span { + margin-right: auto; + color: var(--petricon-muted); +} +.petricon-study-motion button { + padding: 8px 12px; + border: 1px solid var(--petricon-rule); +} +.petricon-study-motion button[aria-pressed="true"] { + border-color: var(--petricon-ink); +} +.petricon-draw-controls { + margin-top: 20px; +} +.petricon-study-pair > div { + min-width: 0; +} +.petricon-study-pair > div + div { + border-left: 1px solid var(--petricon-rule); + padding-left: 16px; +} +.petricon-study-pair > div > span { + color: var(--petricon-red); +} +.petricon-study .petricon-glyph-demo { + min-height: 122px; + padding: 12px 12px 16px; + gap: 24px; +} +.petricon-study .petricon-glyph-demo > span { + font-size: 10px; +} +.petricon-study p { + font-size: 11px; + color: var(--petricon-muted); + margin: 14px 0 22px; +} +.petricon-studies-note { + color: var(--petricon-muted); + font-size: 11px; + padding-top: 20px; +} +.petricon-usage { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 64px; + padding-bottom: 80px; +} +.petricon-usage h2 { + margin-bottom: 24px; +} +.petricon-usage p { + font-size: 13px; + line-height: 1.8; + color: var(--petricon-muted); +} +.petricon-usage .petricon-usage-note { + font-size: 11px; + margin-top: 20px; +} +.petricon-text-link { + display: inline-block; + margin-top: 26px; + border-bottom: 1px solid var(--petricon-ink); + font-size: 12px; + padding-bottom: 6px; +} +.petricon-usage > div + div { + border-top: 1px solid var(--petricon-ink); + padding-top: 16px; +} +.petricon-usage-code { + overflow: auto; + font-family: "JetBrains Mono", monospace; + font-size: 12px !important; + line-height: 1.8; + padding-top: 22px; +} +.petricon-footer { + border-top: 1px solid var(--petricon-ink); + display: flex; + align-items: end; + justify-content: space-between; + gap: 30px; + padding: 30px 0 36px; +} +.petricon-footer > span { + font-size: 28px; + font-weight: 600; + letter-spacing: -0.05em; +} +.petricon-footer > span > span { + color: var(--petricon-red); +} +.petricon-footer small { + display: block; + font-weight: 400; + font-size: 10px; + letter-spacing: 0; + color: var(--petricon-muted); + padding-top: 6px; +} +.petricon-footer p, +.petricon-footer > a { + font-size: 10px; + line-height: 1.8; + color: var(--petricon-muted); +} +.petricon-footer p a { + text-decoration: underline; + text-underline-offset: 3px; +} +.petricon-sr-only, +.petricon-skip:not(:focus) { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} +.petricon-skip:focus { + display: block; + padding: 12px; +} +@media (min-width: 1450px) { + .petricon-icon-grid { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } +} +@media (max-width: 1100px) { + .petricon-sheet { + padding-inline: 32px; + } + .petricon-workbench { + grid-template-columns: minmax(0, 1fr) 280px; + gap: 24px; + } + .petricon-icon-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} +@media (max-width: 760px) { + .petricon-sheet { + padding-inline: 20px; + } + .petricon-nav { + height: 68px; + gap: 12px; + } + .petricon-home { + font-size: 9px; + gap: 8px; + } + .petricon-nav nav { + font-size: 10px; + gap: 12px; + } + .petricon-hero-top > span:last-child { + display: none; + } + .petricon-hero h1 { + font-size: clamp(84px, 20vw, 150px); + margin-left: -4px; + } + .petricon-hero-bottom { + grid-template-columns: 1fr 1fr; + gap: 22px; + } + .petricon-hero-bottom h2 { + font-size: 28px; + } + .petricon-hero-bottom p { + font-size: 11px; + } + .petricon-cta { + font-size: 10px; + gap: 12px; + } + .petricon-edition { + display: none; + } + .petricon-hero-demos { + grid-template-columns: repeat(2, 1fr); + } + .petricon-hero-demos > button:nth-child(3) { + border-left: 0; + } + .petricon-hero-demos > button:nth-child(n + 3) { + border-top: 1px solid var(--petricon-rule); + } + .petricon-principles { + gap: 18px; + } + .petricon-principles p { + font-size: 10px; + } + .petricon-principles span { + font-size: 8px; + } + .petricon-section { + padding-top: 56px; + } + .petricon-section-heading { + display: block; + } + .petricon-section-heading > p { + margin-top: 20px; + font-size: 11px; + min-width: 0; + } + .petricon-workbench { + grid-template-columns: 1fr; + } + .petricon-inspector { + position: static; + grid-row: 1; + } + .petricon-specimen { + max-height: 160px; + } + .petricon-sliders { + grid-template-columns: repeat(3, 1fr); + gap: 18px; + } + .petricon-icon-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .petricon-filters { + gap: 15px; + } + .petricon-filters button { + font-size: 10px; + } + .petricon-studies-grid { + grid-template-columns: 1fr; + } + .petricon-usage { + grid-template-columns: 1fr; + gap: 40px; + } + .petricon-footer { + flex-wrap: wrap; + } +} +@media (prefers-reduced-motion: reduce) { + .petricon-page { + scroll-behavior: auto; + } + .petricon-page *, + .petricon-page *::before, + .petricon-page *::after { + transition: none !important; + } +} diff --git a/apps/petrinaut-website/src/petricon-page/playground.tsx b/apps/petrinaut-website/src/petricon-page/playground.tsx new file mode 100644 index 00000000000..2aae1f9a388 --- /dev/null +++ b/apps/petrinaut-website/src/petricon-page/playground.tsx @@ -0,0 +1,416 @@ +/** + * @layerRoot website.petricon + * @role Petricon's interactive collection, state inspector, and design studies + */ +import { useState } from "react"; + +import { + Petricon, + PetriconProvider, + petriconCatalog, + petriconEffects, + getPetriconEffects, + type PetriconName, + type PetriconEffect, + type PetriconTransition, + type PetriconVariant, + type PetriconChoreography, +} from "@hashintel/petrinaut/ui"; + +import { getGlyphState, statefulGlyphs } from "./glyph-demo"; + +const Inspector = ({ + name, + motion, +}: { + name: PetriconName; + motion: boolean; +}) => { + const [weight, setWeight] = useState(400); + const [size, setSize] = useState(72); + const [duration, setDuration] = useState(420); + const [step, setStep] = useState(0); + const [replay, setReplay] = useState(0); + const [effect, setEffect] = useState( + statefulGlyphs.has(name) ? "state" : "action", + ); + const [choreography, setChoreography] = + useState("stagger"); + const [manualDraw, setManualDraw] = useState(false); + const [drawProgress, setDrawProgress] = useState(1); + const [transition, setTransition] = useState("spring"); + const [variant, setVariant] = useState("outline"); + const [copied, setCopied] = useState(""); + const entry = petriconCatalog.find((candidate) => candidate.name === name); + const state = getGlyphState(name, step); + const stateAttribute = + statefulGlyphs.has(name) && effect === "state" + ? Object.entries(state) + .map(([attribute, value]) => + typeof value === "boolean" + ? `${attribute}={${String(value)}}` + : `${attribute}="${String(value)}"`, + ) + .join(" ") + : effect === "draw" && manualDraw + ? `drawProgress={${drawProgress}}` + : `effect="${effect}" trigger={replay}`; + const snippet = ``; + + return ( + + ); +}; + +export const Playground = () => { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("All"); + const [selected, setSelected] = useState("clockRotateLeft"); + const [expanded, setExpanded] = useState(false); + const [motion, setMotion] = useState(true); + const categories = [ + "All", + "Modeling", + "Navigation", + "Editing", + "Simulation", + "Math", + "Code", + "AI", + "Interface", + ]; + const filtered = petriconCatalog.filter( + (entry) => + (category === "All" || entry.category === category) && + `${entry.name} ${entry.label} ${entry.study ?? ""}` + .toLowerCase() + .includes(query.trim().toLowerCase()), + ); + const visible = + expanded || query || category !== "All" ? filtered : filtered.slice(0, 36); + + return ( + +
+ + +
+
+ {categories.map((option) => ( + + ))} +
+
+
+
+ {visible.map((entry) => ( + + ))} +
+ {visible.length === 0 && ( +

+ No icons match “{query}”. Try a different name or category. +

+ )} + {category === "All" && !query && ( + + )} +
+ +
+
+ ); +}; diff --git a/apps/petrinaut-website/src/petricon-page/studies.tsx b/apps/petrinaut-website/src/petricon-page/studies.tsx new file mode 100644 index 00000000000..aaa2b86885c --- /dev/null +++ b/apps/petrinaut-website/src/petricon-page/studies.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; + +import { petriconStudies, PetriconProvider } from "@hashintel/petrinaut/ui"; + +import { GlyphDemo } from "./glyph-demo"; + +export const Studies = () => { + const [category, setCategory] = useState("Modeling"); + const [draw, setDraw] = useState(false); + return ( + +
+ {["Modeling", "Math", "Code", "AI"].map((option) => ( + + ))} +
+ {category === "Modeling" && ( +
+ Click a symbol to compare + + +
+ )} +
+ {petriconStudies + .filter((study) => study.category === category) + .map((study, index) => ( +
+
+

{study.concept}

+ 0{index + 1} +
+
+ {study.names.map((name, variant) => ( +
+ + {String.fromCharCode(65 + variant)} + + +
+ ))} +
+

{study.motion}

+
+ ))} +
+

+ Multiple directions per concept. Hover for a hint; click for its action. + Modeling studies also draw on, stroke by stroke. Find each variation in + the collection above. +

+
+ ); +}; diff --git a/apps/petrinaut-website/src/routes/petricon.tsx b/apps/petrinaut-website/src/routes/petricon.tsx new file mode 100644 index 00000000000..1b97b9eb286 --- /dev/null +++ b/apps/petrinaut-website/src/routes/petricon.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { PetriconPage } from "../petricon-page"; + +export const Route = createFileRoute("/petricon")({ + component: PetriconPage, +}); diff --git a/libs/@hashintel/petrinaut/README.md b/libs/@hashintel/petrinaut/README.md index 7deb4be3f31..ee2e5e38923 100644 --- a/libs/@hashintel/petrinaut/README.md +++ b/libs/@hashintel/petrinaut/README.md @@ -85,6 +85,180 @@ registries. The guide, with a reference palette, lives in the architecture docs: [Commands and the palette](https://github.com/hashintel/hash/blob/main/libs/%40local/petrinaut-arch-docs/content/commands/usage-manual.mdx). +## Petricon + +Enable **Petricon (Experimental)** in the viewport settings to use Petricon, the custom +icons throughout the editor. The preference is saved in the browser. Names +outside the experimental pack use their default design-system icons. + +The `Petricon` and `PetriconProvider` exports are the named API. The earlier +`ExperimentalIcon` and `ExperimentalIconProvider` exports remain compatible. +`petriconCatalog` describes every icon and its motion; `petriconStudies` groups +alternative designs for math, code, and AI. The website route `/petricon` provides +a searchable collection, state inspector, and paired studies. + +The pack covers editor tools, navigation, editing actions, diagnostics, simulation +and data views, entity symbols, and assistant and voice controls. + +The SVG components are also available from `@hashintel/petrinaut/ui`: + +```tsx +import { Petricon, PetriconProvider, PlaceIcon } from "@hashintel/petrinaut/ui"; + + + + +; +``` + +`size` sets the SVG dimensions in pixels. `weight` accepts values from 100 to +700 and defaults to 400; values outside the range are clamped. `variant` accepts +`outline` or `filled` and defaults to `outline`. Filled variants fill icon +silhouettes and preserve interior symbols. Open strokes retain their shape. +The icons inherit `currentColor`, accept +standard SVG props and refs, and are decorative unless given an accessible +label. Weight adjusts stroke thickness on a shared 24-unit grid. + +The provider sets size, weight, color, motion, and duration defaults for custom icons, and supplies +the experimental pack to design-system components in its tree, including portals. +Design-system `Icon` sizes continue to follow their existing size tokens. +Use `enabled={false}` to restore the default design-system pack. Explicit custom +icon components still render their requested icon. Nested providers inherit defaults +and can override individual values. + +`experimentalIconNames` lists the custom icon names. `experimentalIconPack` maps +design-system names to replacements and can also be passed to the design-system +`IconProvider` as its `icons` prop. Every current design-system icon has a +replacement, including icons rendered internally by shared controls. Partial +packs passed to `IconProvider` still fall back to the default artwork. +Checkboxes use the pack's check and dash. Its `loadingSpinner` override uses +`LoadingIcon`, whose rotation follows the same motion preferences as other icons. + +### Effects and transitions + +```tsx +; +; +; +; +``` + +`effect` accepts `action`, `bounce`, `pulse`, `rotate`, or `draw`, or an array that combines +effects. `action` plays the icon's authored response: clocks rewind, parameters +scrub, and subnet cubes make a quarter-turn. Interactive controls play this response +on click when no explicit effect is supplied. Use `interaction="none"` to opt out. +`draw` is supported by Add Place, Add Transition, history/reset, parameters, the +subnet cube, and all modeling studies. The add frame and corner badge draw in order, +and filled add frames use a reveal mask. +`getPetriconEffects(name)` lists the effects supported by an icon. +Unsupported effects leave the artwork unchanged. + +For controlled drawing, use ``. +Progress is clamped to 0–1, takes priority over a timed draw effect, and remains +available with motion disabled. Omit it to restore the complete symbol. A timed +draw supports `choreography="together"`, `"stagger"`, or `"sequential"`. + +The Modeling studies compare three alternatives each for differential equations +(`differentialEquation`, `equationCurve`, `equationFlow`), parameters (`parameter`, +`parameterDial`, `parameterRange`), variables (`variable`, `variableBrackets`, +`variableRegister`), token types (`tokenType`, `tokenTypeStack`, `tokenTypeTag`), +and subnets (`cube`, `subnetNetwork`, `subnetLayers`). The website and Storybook's +Modeling Studies story expose both their action and drawing effects. + +Interactive icons give small hover hints: arrows move in their direction, the +trash lid lifts, the copy sheet separates, and chart bars rise slightly. These +hints apply inside buttons, links, tabs, segmented controls, checkboxes, and highlighted menu items. Decorative +icons stay still. Set `hover="none"` to disable an icon's hint. Disabled controls +and reduced-motion preferences suppress hover motion. + +`` moves each shape independently on hover: +the triangle turns right, the square and diamond tilt, and the circle nudges up. +Selection settles the shapes into a different arrangement, with a right-facing +triangle and the diamond turned into an upright square. Both resting states +keep the four shapes aligned, with the square upright. It also follows a +containing segmented control's checked state or button's `aria-pressed` state. +The editor binds this to Edit mode. These transitions take up to 320ms, with +short delays between shapes; reduced motion preserves the selected arrangement. + +`` previews an adjustment on hover or keyboard focus: its knobs +scrub along stationary tracks and return to their original positions. The preview +plays once, lasts up to 480ms, and does not change any parameter values. + +`` has two overlapping liquid surfaces clipped +inside its outline. The surface is flat at rest. Hovering deforms it into a wave +that travels across the flask and settles back to flat, even if the pointer +leaves before it finishes. The glass stays still. Selection raises the +liquid slightly. `` separates its layers on +hover and selection. Both respond to pressing their control, then settle when +released. They also follow a containing segmented control's checked state or a +button's `aria-pressed` state, using the shared motion and transition props. +Their state transitions are capped at 300ms for quick feedback; the flask's hover +wave lasts up to 480ms and replays when hovered again. The translucent fill and +surface line keep the liquid visible at small icon sizes. + +`` transitions between menu bars and a close symbol. +It also follows a containing button's `aria-expanded` state. +`` transitions between play and pause, and +`` draws or removes its mute slash. The editor +connects these icons to the corresponding control states. These transitions use +the shared `duration`, `transition`, and `motion` props. + +`` transitions between `valid`, `warning`, and +`error` drawings in one SVG. The editor drives it from the language client's +validation counts, so it animates when issues appear or are fixed. + +With `trigger`, a one-off effect plays when that string or number changes; it does +not play on mount. Without a trigger it plays on mount and when the effect or +artwork changes. `active={true}` loops; `active={false}` stops. New effects cancel +interrupted ones, and unmounting releases their animations. + +`duration` is the complete cycle in milliseconds, including layer staggering. It +defaults to 600, clamps to 100–10000, and replaces non-finite values with 600. +`choreography` accepts `together`, `stagger` (default), or `sequential`. + +The add icons show a plain circle or square at rest. Their plus badge appears at +the bottom-right on hover, including when their containing button is hovered. +`badge` accepts `hover` (default), `visible`, or `hidden`. The badge has a transparent +gap around it so it remains distinct from the frame on any background. + +For the add icons, `selected` enlarges the frame. Changes to `variant` +transition between outline and filled. Changing +`name` between `addPlace` and `addTransition` on the same `Petricon` morphs +the frame between a circle and a rounded square. `transition` accepts `smooth` +(default), `spring` (an overshooting easing curve), or `none`. Other icon pairs +switch immediately; they have no authored morph yet. + +`SidebarIcon` accepts `collapsed` (default `false`). When collapsed, a short +thin vertical line stays close to the left edge of the frame. Hovering restores +its regular weight and moves it slightly right. Expanding restores the full-height +divider, positioned left of center, with a small leftward nudge on hover to suggest closing. +The outer frame stays still. It supports +outline and filled styles and uses the same transition, duration, and motion +settings. The editor connects this state to the sidebar toggle. + +`SettingsIcon` has sharp gear teeth and accepts `open` (default `false`). Use +`` to turn it 30° while its +menu is open and return it to its resting angle when closed. Both settings buttons +in the editor follow their menu's state, including dismissal with Escape or an +outside click. The same transition, duration, and motion settings apply. + +`motion="none"` disables effects and transitions while preserving the final +appearance. System reduced-motion preferences also disable motion. The editor +connects this to its **Animations** setting. With the experimental pack enabled, +selecting either add tool fills its icon and plays a short bounce. Motion runs +through inline SVG and browser animation APIs; no animation dependency is needed. + ## Storybook Run Petrinaut's component stories from the repository root: @@ -93,6 +267,20 @@ Run Petrinaut's component stories from the repository root: yarn workspace @hashintel/petrinaut dev ``` +`DiagnosticsIcon` reuses two strokes across `status="valid"`, `"warning"`, and +`"error"`. The strokes move, rotate, and change length to form the check, +exclamation mark, and cross. The editor badge slides its count out from behind +the icon through a gradient mask and resizes smoothly as the count appears, +disappears, or gains digits. Motion settings apply to the entire badge. + +The **Petrinaut / Petricon / Catalog** story includes search, +weight and size controls, outline/filled comparison, and a fallback example. Its +motion playground includes experiment and scenario controls, sidebar, settings, menu, playback, microphone, and diagnostics +state transitions, replay, looping, combined effects, layer timing, selection +transitions, plus-badge visibility, and the Add Place ↔ Add Transition morph. +The diagnostics button demo also includes a count control for checking its resize +and reveal transitions. + The **Simulate / SimulateView / Run Supply Chain optimization (synthetic optimizer)** story creates a parameter sweep with its study, as the Create Experiment drawer's Optimize does, and drives it with an internal fake diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index bb3474d7e79..c98627b0019 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -8,6 +8,77 @@ preferences and are not reverted by browser navigation. ## Available settings +### Petricon + +In the **Viewport** section, turn on **Petricon (Experimental)** to use Petricon, Petrinaut's +custom icons for entities, playback, zoom, and navigation. The pack includes the +sidebar toggle, main menu, pan hand and compact selection arrow, add-place and add-transition +buttons, settings gears, editing actions, diagnostics, simulation and data views, +and assistant and voice controls, including icons inside shared menus and forms. +Checkbox marks and loading indicators also use the pack. +The change applies immediately, and turning it off +restores the default icons. + +**Add Place** and **Add Transition** show a plain circle and square. Hover over +either button to reveal a plus at the bottom-right of its shape; the plus disappears +when you move away. Selecting either tool fills its icon and plays a short bounce. Turning off **Animations** below keeps the selection feedback and removes +the motion. Icons also respect your device's reduced-motion preference. +The sidebar toggle keeps a thin line near the left edge of its frame when the +sidebar is collapsed. Hovering makes it thicker and moves it slightly right. +When open, the full-height divider sits left of center and nudges a little +left on hover to suggest closing. The outer frame stays still. +Settings gears have sharp teeth and turn slightly while their menu is open, +returning to their resting angle when it closes. Turning off **Animations** +changes the angle immediately. + +Other interactive icons give small action hints on hover: the trash lid lifts, +the copy sheets separate, and arrows move slightly in their direction. The main +menu icon changes when its menu opens, the playback icon changes between play +and pause, and the microphone draws a slash when muted. These transitions follow +the control's state. Decorative icons stay still, and disabled controls do not +animate. The **Animations** setting and your device's reduced-motion preference +apply throughout the pack. + +Clicking an action icon gives a brief response that follows its purpose: arrows +move in their direction, sheets separate for copying, and parameter knobs scrub +along their tracks. These movements settle back without changing the control's +meaning. Controls with a persistent state keep their existing state animation. + +**Recent changes** uses a clock with a return arrow. Hovering or opening its menu +briefly rewinds the hands. The subnet cube turns in depth on hover and makes a +quarter-turn when clicked; rear edges stay hidden throughout the turn. + +The AI assistant button uses a robot head. Hovering makes its eyes blink and +its antenna move briefly before settling. + +Hovering over **Edit** turns the triangle toward the right, tilts the square and +diamond separately, and nudges the circle. Selecting **Edit** moves the shapes +into a different resting arrangement, which stays until you leave Edit mode. +Both resting states keep the shapes aligned and the square upright. +Hovering while Edit is selected adds smaller movements to that arrangement. + +Hovering over a parameter icon briefly scrubs its slider knobs along their tracks, +then returns them to their starting positions. The tracks stay horizontal. This +is a visual preview; your parameter values stay the same. + +In **Simulate**, the **Experiments** flask has a flat liquid surface at rest. +Hovering sends a wave across the liquid, which then settles back to flat even if +you move away. The glass stays still, and the shaded fill makes the fluid visible +at small sizes. Selecting +it raises the liquid slightly. The **Scenarios** icon gently separates its layers +on hover and selection. Both respond quickly to pressing and settle when released; +moving away returns them to the selected or unselected appearance. + +The diagnostics indicator transitions between a green check when valid, an +amber exclamation mark for warnings, and a red cross for errors. The same two +strokes move and change length between these shapes. The issue count slides +out from behind the icon with a soft fade, and the badge smoothly resizes around +it. Resolving every issue slides the count back away. These animations follow +validation results; clicking the indicator opens Diagnostics. + +The pack is off by default. Your choice is saved in this browser and restored +when you reopen Petrinaut. + ### Animations Toggle panel transition and UI interaction animations. Disable for a snappier feel or if animations cause performance issues. The results drawers follow it too: the header's condensing, the cards' folds and bar fills, and the purple pulse of a sweep's **Parameters** card while the optimizer drives it all hold still when animations are off, or when your system asks for reduced motion. diff --git a/libs/@hashintel/petrinaut/src/panda-preset.ts b/libs/@hashintel/petrinaut/src/panda-preset.ts index 6af949c76f8..30380652304 100644 --- a/libs/@hashintel/petrinaut/src/panda-preset.ts +++ b/libs/@hashintel/petrinaut/src/panda-preset.ts @@ -40,6 +40,54 @@ export const petrinautPandaPreset = { }, }, keyframes: { + petriconSequence: { + "0%, 100%": { transform: "translateY(0)", opacity: "1" }, + "40%": { transform: "translateY(-1px)", opacity: "0.72" }, + }, + petriconNotation: { + "0%, 100%": { transform: "translate(0, 0)" }, + "35%": { transform: "translate(1px, -0.5px)" }, + "70%": { transform: "translate(-0.4px, 0.2px)" }, + }, + petriconSignal: { + "0%, 100%": { transform: "scale(1)", opacity: "1" }, + "35%": { transform: "scale(0.9)", opacity: "0.65" }, + "65%": { transform: "scale(1.04)", opacity: "1" }, + }, + petriconDistribution: { + "0%, 100%": { transform: "translateX(0) skewX(0)" }, + "30%": { transform: "translateX(-0.5px) skewX(-5deg)" }, + "65%": { transform: "translateX(0.3px) skewX(3deg)" }, + }, + petriconOrbit: { + "0%, 100%": { transform: "rotate(0deg)" }, + "40%": { transform: "rotate(14deg)" }, + "75%": { transform: "rotate(-3deg)" }, + }, + petriconBlink: { + "0%, 100%": { transform: "scaleY(1)" }, + "40%": { transform: "scaleY(0.2)" }, + }, + petriconScan: { + "0%, 100%": { transform: "scaleY(1)", opacity: "1" }, + "45%": { transform: "scaleY(0.8)", opacity: "0.5" }, + }, + petrinautIconParameterScrub: { + "0%, 100%": { transform: "translateX(0)" }, + "30%": { + transform: + "translateX(calc(var(--parameter-scrub-direction) * 3px))", + }, + "65%": { + transform: + "translateX(calc(var(--parameter-scrub-direction) * -1px))", + }, + }, + petrinautIconLiquidWave: { + "0%, 100%": { transform: "translate(0, 0)" }, + "25%": { transform: "translate(1px, -0.5px)" }, + "65%": { transform: "translate(-0.5px, 0)" }, + }, petrinautFadeIn: { from: { opacity: "0", transform: "translateY(-10px)" }, to: { opacity: "1", transform: "translateY(0)" }, diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts index 4b418b46c5e..5271fba5310 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -34,6 +34,7 @@ export type UserSettings = { showAnimations: boolean; keepPanelsMounted: boolean; compactNodes: boolean; + enableExperimentalIconPack: boolean; arcRendering: ArcRendering; cursorMode: CursorMode; isLeftSidebarOpen: boolean; @@ -112,6 +113,7 @@ export type UserSettingsActions = { setShowAnimations: (value: boolean) => void; setKeepPanelsMounted: (value: boolean) => void; setCompactNodes: (value: boolean) => void; + setEnableExperimentalIconPack: (value: boolean) => void; setArcRendering: (value: ArcRendering) => void; setIsLeftSidebarOpen: (value: boolean) => void; setLeftSidebarWidth: (value: number) => void; @@ -147,6 +149,7 @@ export const defaultUserSettings: UserSettings = { showAnimations: true, keepPanelsMounted: true, compactNodes: false, + enableExperimentalIconPack: false, arcRendering: "custom", cursorMode: "pan", isLeftSidebarOpen: true, @@ -181,6 +184,7 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = { setShowAnimations: () => {}, setKeepPanelsMounted: () => {}, setCompactNodes: () => {}, + setEnableExperimentalIconPack: () => {}, setArcRendering: () => {}, setIsLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx index 7d04b508de2..7c0a250e88d 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -92,6 +92,11 @@ const OwnedUserSettingsProvider: React.FC = ({ setState((prev) => ({ ...prev, keepPanelsMounted: value })), setCompactNodes: (value: boolean) => setState((prev) => ({ ...prev, compactNodes: value })), + setEnableExperimentalIconPack: (value: boolean) => + setState((settings) => ({ + ...settings, + enableExperimentalIconPack: value, + })), setArcRendering: (value: ArcRendering) => setState((prev) => ({ ...prev, arcRendering: value })), setCursorMode: (value: CursorMode) => diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/gutter-menu.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/gutter-menu.tsx index b777956d50e..db9d44da045 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/gutter-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/gutter-menu.tsx @@ -11,6 +11,11 @@ import { useRef } from "react"; import { Popover } from "@hashintel/ds-components"; import { css, cx } from "@hashintel/ds-helpers/css"; +import { + CheckIcon, + useExperimentalIconPackEnabled, +} from "../../../experimental-icons"; + const menuStyle = css({ display: "flex", flexDirection: "column", @@ -72,6 +77,7 @@ export const GutterMenu: React.FC = ({ onDismiss, }) => { const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + const experimentalIcons = useExperimentalIconPackEnabled(); const focusedOnOpenRef = useRef(false); // The Popover focuses this when it opens; the initial item's ref fills it. const initialItemRef = useRef(null); @@ -152,7 +158,13 @@ export const GutterMenu: React.FC = ({ > {hasCheckable ? ( ) : null} {item.label} diff --git a/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx b/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx index bf16420cb48..094a2a50fbc 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx @@ -1,3 +1,8 @@ +import { + ExperimentalIcon, + useExperimentalIconPackEnabled, +} from "../experimental-icons"; + import type { SVGProps } from "react"; type AiAssistantIconProps = SVGProps & { @@ -9,45 +14,59 @@ export const AiAssistantIcon = ({ size = 15, title, ...props -}: AiAssistantIconProps) => ( - - {title && {title}} - - - - - -); +}: AiAssistantIconProps) => { + const experimentalIcons = useExperimentalIconPackEnabled(); + if (experimentalIcons) { + return ( + + ); + } + return ( + + {title && {title}} + + + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons.stories.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons.stories.tsx new file mode 100644 index 00000000000..5adcd92bcf2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons.stories.tsx @@ -0,0 +1,750 @@ +import { useState } from "react"; + +import { + Checkbox, + Icon, + IconProvider, + LoadingSpinner, + SegmentedControl, +} from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { + DEFAULT_LANGUAGE_CLIENT_CONTEXT, + LanguageClientContext, +} from "../react/lsp/context"; +import { + ExperimentalIcon, + ExperimentalIconProvider, + experimentalIconNames, + experimentalIconEffects, + PlayIcon, +} from "./experimental-icons"; +import { petriconStudies } from "./petricon"; +import { DiagnosticsIndicator } from "./views/Editor/components/BottomBar/diagnostics-indicator"; + +import type { + ExperimentalIconVariant, + ExperimentalIconEffect, + ExperimentalIconChoreography, + ExperimentalIconTransition, + ExperimentalIconBadgeVisibility, + ExperimentalIconStatus, + ExperimentalIconName, +} from "./experimental-icons"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const galleryStyle = css({ + padding: "6", + color: "neutral.fg.heading", + backgroundColor: "neutral.bg.surface", + minHeight: "[100vh]", +}); + +const controlsStyle = css({ + display: "flex", + flexWrap: "wrap", + gap: "6", + alignItems: "center", + marginY: "6", + fontSize: "sm", +}); + +const gridStyle = css({ + display: "grid", + gridTemplateColumns: "[repeat(auto-fill, minmax(160px, 1fr))]", + gap: "4", +}); + +const tileStyle = css({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "4", + padding: "6", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "neutral.a30", + borderRadius: "lg", + fontSize: "xs", +}); + +const titleStyle = css({ fontSize: "xl", fontWeight: "semibold" }); +const noteStyle = css({ fontSize: "sm", marginTop: "2" }); +const inputStyle = css({ + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "neutral.a50", + borderRadius: "md", + paddingX: "2", + paddingY: "1", +}); + +const MotionShowcase = ({ + variant, + motion, + onMotionChange, +}: { + variant: ExperimentalIconVariant; + motion: boolean; + onMotionChange: (enabled: boolean) => void; +}) => { + const [effect, setEffect] = useState("bounce"); + const [duration, setDuration] = useState(700); + const [choreography, setChoreography] = + useState("stagger"); + const [transition, setTransition] = + useState("spring"); + const [trigger, setTrigger] = useState(0); + const [looping, setLooping] = useState(false); + const [withPulse, setWithPulse] = useState(false); + const [sampleChecked, setSampleChecked] = useState(true); + const [editSelected, setEditSelected] = useState(false); + const [simulateMode, setSimulateMode] = useState<"experiments" | "scenarios">( + "experiments", + ); + const [badge, setBadge] = useState("hover"); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [playing, setPlaying] = useState(false); + const [muted, setMuted] = useState(false); + const [diagnosticStatus, setDiagnosticStatus] = + useState("valid"); + const [diagnosticCount, setDiagnosticCount] = useState(3); + const [selected, setSelected] = useState<"addPlace" | "addTransition" | null>( + null, + ); + const [morphName, setMorphName] = useState<"addPlace" | "addTransition">( + "addPlace", + ); + const effects = + withPulse && effect !== "pulse" ? [effect, "pulse" as const] : effect; + const cycleDiagnostics = () => + setDiagnosticStatus( + diagnosticStatus === "valid" + ? "error" + : diagnosticStatus === "error" + ? "warning" + : "valid", + ); + + return ( +
+

Effects and transitions

+

+ Replay an effect, select a tool, or change the shape. Motion follows + your system preference. Hover over an add icon to reveal its plus. +

+
+ + + + + + + + + +
+ +
+ + {(["experiments", "scenarios"] as const).map((mode) => ( + + ))} + {(["addPlace", "addTransition"] as const).map((name) => ( + + ))} + + + + + + + +
+
+ Diagnostics button + + + + + + Click the badge to cycle through errors, warnings, and valid. + +
+
+ + + Hover or switch views to move the liquid and separate the layers. + + + {}} + size="sm" + /> + Loading + +
+
+
+ ); +}; + +const Gallery = () => { + const [motion, setMotion] = useState(true); + const [query, setQuery] = useState(""); + const [weight, setWeight] = useState(400); + const [size, setSize] = useState(24); + const [variant, setVariant] = useState("outline"); + const names = experimentalIconNames.filter((name) => + name.toLowerCase().includes(query.toLowerCase()), + ); + + return ( +
+

Petricon

+

+ Petrinaut SVG icons. Compare their weight, size, and filled shapes. + Hover over a tile to preview its action hint. +

+
+ + + + +
+ + +
+ {names.map((name) => ( +
+ + {name} +
+ ))} +
+

+ Partial-pack fallback example: only play is overridden here.{" "} + + + +

+
+
+ ); +}; + +const meta = { + title: "Petrinaut/Petricon", + id: "petrinaut-experimental-icon-pack", + component: Gallery, + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Catalog: Story = {}; + +const ModelingSample = ({ + name, + draw, + progress, +}: { + name: ExperimentalIconName; + draw: boolean; + progress: number | undefined; +}) => { + const [trigger, setTrigger] = useState(0); + return ( + + ); +}; + +const ModelingShowcase = () => { + const [draw, setDraw] = useState(false); + const [scrub, setScrub] = useState(false); + const [progress, setProgress] = useState(1); + return ( + +
+

Modeling studies

+

+ Hover for a hint. Click for an action, or draw each symbol + progressively. +

+
+ + + setProgress(event.target.valueAsNumber)} + /> +
+
+ + +
+ {petriconStudies + .filter((study) => study.category === "Modeling") + .map((study) => ( +
+

{study.concept}

+

{study.motion}

+
+ {study.names.map((name) => ( + + ))} +
+
+ ))} +
+
+ ); +}; + +export const ModelingStudies: Story = { render: () => }; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx new file mode 100644 index 00000000000..5766a6d1414 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx @@ -0,0 +1,598 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +/** @vitest-environment jsdom */ +import { createRef, use } from "react"; +import { createPortal } from "react-dom"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Icon, IconProvider, iconNames } from "@hashintel/ds-components"; + +import { UserSettingsContext } from "../react/state/user-settings-context"; +import { UserSettingsProvider } from "../react/state/user-settings-provider"; +import { AiAssistantIcon } from "./components/ai-assistant-icon"; +import { + ExperimentalIcon, + ExperimentalIconProvider, + PlaceIcon, + AddPlaceIcon, + AddTransitionIcon, + getExperimentalIconEffects, + PlayIcon, +} from "./experimental-icons"; +import { petriconStudies } from "./petricon"; + +const SettingsExample = () => { + const { enableExperimentalIconPack, setEnableExperimentalIconPack } = + use(UserSettingsContext); + return ( + + + + + + + + {createPortal(, document.body)} + + ); +}; + +beforeEach(() => localStorage.clear()); +afterEach(cleanup); + +describe("experimental icon preference", () => { + it("switches immediately, persists across mounts, and restores the defaults", () => { + localStorage.setItem( + "petrinaut:user-settings", + JSON.stringify({ showMinimap: false }), + ); + const { unmount } = render( + + + , + ); + const originalPlay = screen.getByRole("img", { name: "Play" }).outerHTML; + const originalBarcode = screen.getByRole("img", { + name: "Barcode", + }).outerHTML; + const originalAssistant = screen.getByRole("img", { + name: "Assistant", + }).outerHTML; + expect(originalPlay).not.toContain("petrinaut-experimental"); + + fireEvent.click(screen.getByRole("button", { name: "Toggle icon pack" })); + expect( + screen.getByRole("img", { name: "Play" }).getAttribute("data-icon-pack"), + ).toBe("petrinaut-experimental"); + expect( + screen.getByRole("img", { name: "Assistant" }).getAttribute("data-icon"), + ).toBe("agent"); + expect( + screen + .getByRole("img", { name: "Pause in portal" }) + .getAttribute("data-icon-pack"), + ).toBe("petrinaut-experimental"); + expect(screen.getByRole("img", { name: "Barcode" }).outerHTML).toBe( + originalBarcode, + ); + expect( + JSON.parse(localStorage.getItem("petrinaut:user-settings") ?? "{}"), + ).toMatchObject({ + enableExperimentalIconPack: true, + showMinimap: false, + }); + + unmount(); + render( + + + , + ); + expect( + screen.getByRole("img", { name: "Play" }).getAttribute("data-icon-pack"), + ).toBe("petrinaut-experimental"); + fireEvent.click(screen.getByRole("button", { name: "Toggle icon pack" })); + expect(screen.getByRole("img", { name: "Play" }).outerHTML).toBe( + originalPlay, + ); + expect(screen.getByRole("img", { name: "Assistant" }).outerHTML).toBe( + originalAssistant, + ); + }); +}); + +describe("experimental SVG API", () => { + it("keeps each flask's liquid clipped to its own stable outline", () => { + const Flasks = ({ selected }: { selected: boolean }) => ( + <> + + + + ); + const { container, rerender } = render(); + const clipIds = Array.from( + container.querySelectorAll("clipPath"), + (clip) => clip.id, + ); + expect(clipIds).toHaveLength(2); + expect(new Set(clipIds).size).toBe(2); + for (const flask of container.querySelectorAll('svg[data-icon="flask"]')) { + expect( + flask.querySelector("g[clip-path]")?.getAttribute("clip-path"), + ).toBe(`url(#${flask.querySelector("clipPath")?.id})`); + } + rerender(); + expect( + Array.from(container.querySelectorAll("clipPath"), (clip) => clip.id), + ).toEqual(clipIds); + }); + + it("replaces every icon available to shared app controls", () => { + render( + + {iconNames.map((name) => ( + + ))} + , + ); + for (const name of iconNames) { + expect( + screen.getByRole("img", { name }).getAttribute("data-icon-pack"), + ).toBe("petrinaut-experimental"); + } + }); + + it("inherits defaults and lets individual icons override them", () => { + const { container } = render( + + + + , + ); + const labelled = screen.getByRole("img", { name: "Place" }); + expect(labelled.getAttribute("width")).toBe("20"); + expect(labelled.getAttribute("stroke-width")).toBe("3"); + expect(labelled.getAttribute("color")).toBe("red"); + expect(labelled.hasAttribute("aria-hidden")).toBe(false); + const decorative = container.querySelector('svg[aria-hidden="true"]'); + expect(decorative?.getAttribute("width")).toBe("16"); + expect(decorative?.getAttribute("stroke-width")).toBe("1"); + expect(decorative?.getAttribute("color")).toBe("blue"); + expect(decorative?.getAttribute("fill")).toBe("currentColor"); + }); + + it.each([ + [0, 1], + [800, 3], + [Number.NaN, 2], + [Number.POSITIVE_INFINITY, 2], + ])( + "keeps weight %s within the supported stroke range", + (weight, strokeWidth) => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain(`stroke-width="${strokeWidth}"`); + }, + ); +}); + +describe("experimental icon motion", () => { + const originalAnimate = Object.getOwnPropertyDescriptor( + Element.prototype, + "animate", + ); + const cancel = vi.fn(); + const animate = vi.fn( + (_frames: Keyframe[], _options: KeyframeAnimationOptions) => + ({ cancel }) as unknown as Animation, + ); + const mediaListeners = new Set<() => void>(); + let reducedMotion = false; + + beforeEach(() => { + reducedMotion = false; + animate.mockClear(); + cancel.mockClear(); + mediaListeners.clear(); + Object.defineProperty(Element.prototype, "animate", { + configurable: true, + value: animate, + }); + vi.stubGlobal("matchMedia", () => ({ + get matches() { + return reducedMotion; + }, + addEventListener: (_event: string, listener: () => void) => + mediaListeners.add(listener), + removeEventListener: (_event: string, listener: () => void) => + mediaListeners.delete(listener), + })); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + if (originalAnimate) { + Object.defineProperty(Element.prototype, "animate", originalAnimate); + } else { + Reflect.deleteProperty(Element.prototype, "animate"); + } + }); + + it("replays only when its trigger changes and cancels interrupted effects", () => { + const { rerender, unmount } = render( + , + ); + expect(animate).not.toHaveBeenCalled(); + rerender(); + const animationCount = animate.mock.calls.length; + expect(animationCount).toBeGreaterThan(0); + rerender( + , + ); + expect(animate).toHaveBeenCalledTimes(animationCount); + rerender(); + expect(cancel).toHaveBeenCalledTimes(animationCount); + expect(animate).toHaveBeenCalledTimes(animationCount * 2); + unmount(); + expect(cancel).toHaveBeenCalledTimes(animationCount * 2); + }); + + it("combines effects on separate groups and stops a loop", () => { + const { rerender } = render( + , + ); + expect( + animate.mock.calls.some(([frames]) => + frames.some((frame) => frame.transform), + ), + ).toBe(true); + expect( + animate.mock.calls.some(([frames]) => + frames.some((frame) => frame.opacity), + ), + ).toBe(true); + expect( + animate.mock.calls.every( + ([, options]) => + options.duration === 900 && options.iterations === Infinity, + ), + ).toBe(true); + const count = animate.mock.calls.length; + rerender( + , + ); + expect(cancel).toHaveBeenCalledTimes(count); + expect(animate).toHaveBeenCalledTimes(count); + }); + + it("draws the frame and corner badge in order for both variants", () => { + const { rerender, container } = render( + , + ); + expect(animate).toHaveBeenCalledTimes(3); + expect(animate.mock.calls.map(([frames]) => frames.at(1)?.offset)).toEqual([ + 0, + 1 / 3, + 2 / 3, + ]); + expect( + animate.mock.calls.every( + ([frames]) => + frames.at(0)?.strokeDashoffset === 1 && + frames.at(-1)?.strokeDashoffset === 0, + ), + ).toBe(true); + animate.mockClear(); + rerender( + , + ); + expect(animate).toHaveBeenCalledTimes(3); + expect( + container.querySelector('mask [data-icon-draw="reveal"]'), + ).not.toBeNull(); + expect( + container + .querySelector('rect[fill="currentColor"]') + ?.getAttribute("mask"), + ).toContain("reveal"); + }); + + it("cancels motion when the system preference changes and keeps the selected state", () => { + const { container, rerender } = render( + , + ); + const count = animate.mock.calls.length; + expect(count).toBeGreaterThan(0); + act(() => { + reducedMotion = true; + mediaListeners.forEach((listener) => listener()); + }); + expect(cancel).toHaveBeenCalledTimes(count); + expect(container.querySelector('[data-icon-motion="none"]')).not.toBeNull(); + expect( + container + .querySelector('rect[fill="currentColor"]') + ?.getAttribute("style"), + ).toContain("transition: none"); + rerender( + , + ); + expect(animate).toHaveBeenCalledTimes(count); + }); + + it("starts the flask surface wave together and removes it when motion is disabled", () => { + const { container, rerender } = render(); + const beginWave = vi.fn(); + const surfaces = container.querySelectorAll("animate"); + expect(surfaces.length).toBeGreaterThan(0); + for (const surface of surfaces) { + Object.defineProperty(surface, "beginElement", { value: beginWave }); + } + const bubbles = container.querySelector('[data-icon-part="bubbles"]'); + expect(bubbles).not.toBeNull(); + const hoverAnimation = new Event("animationstart", { bubbles: true }); + Object.defineProperty(hoverAnimation, "animationName", { + value: "petrinautIconLiquidWave", + }); + fireEvent(bubbles!, hoverAnimation); + expect(beginWave).toHaveBeenCalledTimes(surfaces.length); + + rerender(); + expect(container.querySelector("animate")).toBeNull(); + expect(container.querySelector('[data-selected="true"]')).not.toBeNull(); + }); + + it("inherits motion defaults, preserves refs and uses unique masks", () => { + const ref = createRef(); + const { container } = render( + + + + + + , + ); + expect(animate).not.toHaveBeenCalled(); + expect(ref.current).toBe(screen.getByRole("img", { name: "Add place" })); + expect(ref.current?.getAttribute("stroke-width")).toBe("3"); + const maskIds = Array.from( + container.querySelectorAll("mask"), + (mask) => mask.id, + ); + expect(new Set(maskIds).size).toBe(maskIds.length); + }); + + it.each([100, 420, 600, 700, 900, 2400])( + "keeps staggered keyframes valid at %s milliseconds", + (duration) => { + render( + , + ); + for (const [frames] of animate.mock.calls) { + const offsets = frames.map((frame) => frame.offset ?? 0); + expect(offsets.every((offset) => offset >= 0 && offset <= 1)).toBe( + true, + ); + expect(offsets).toEqual( + offsets.toSorted((left, right) => left - right), + ); + } + }, + ); + + it("keeps the same SVG frame for the place-to-transition morph", () => { + const { container, rerender } = render( + , + ); + const frame = container.querySelector('[data-icon-draw="frame"]'); + rerender(); + expect(container.querySelector('[data-icon-draw="frame"]')).toBe(frame); + expect(frame?.getAttribute("rx")).toBe("2"); + expect(getExperimentalIconEffects("addPlace")).toContain("draw"); + expect(getExperimentalIconEffects("settings")).not.toContain("draw"); + }); + + it("rewinds the history details on hover and click without rotating the entire icon", () => { + const { container, unmount } = render( + , + ); + const button = screen.getByRole("button"); + fireEvent.pointerEnter(button); + expect(animate).toHaveBeenCalledTimes(2); + expect( + animate.mock.calls.some(([frames]) => + frames.some((frame) => frame.transform === "rotate(-100deg)"), + ), + ).toBe(true); + fireEvent.click(button); + expect(cancel).toHaveBeenCalledTimes(2); + expect(animate).toHaveBeenCalledTimes(4); + expect( + container.querySelector("[data-icon-feedback]")?.getAttribute("style"), + ).not.toContain("rotate"); + unmount(); + expect(cancel).toHaveBeenCalledTimes(4); + }); + + it("suppresses automatic interaction for disabled controls and motion opt-outs", () => { + const { rerender } = render( + , + ); + fireEvent.pointerEnter(screen.getByRole("button")); + fireEvent.click(screen.getByRole("button")); + expect(animate).not.toHaveBeenCalled(); + rerender( + , + ); + fireEvent.pointerEnter(screen.getByRole("button")); + fireEvent.click(screen.getByRole("button")); + expect(animate).not.toHaveBeenCalled(); + rerender( + , + ); + fireEvent.click(screen.getByRole("button")); + expect(animate).not.toHaveBeenCalled(); + }); + + it("scrubs both visible parameter knobs and their track masks together on click", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button")); + expect(animate).toHaveBeenCalledTimes(4); + const offsets = animate.mock.calls.map(([frames]) => frames[1]?.transform); + expect( + offsets.filter((transform) => transform === "translateX(3px)"), + ).toHaveLength(2); + expect( + offsets.filter((transform) => transform === "translateX(-3px)"), + ).toHaveLength(2); + }); + + it("offers matching labels and drawable paths for every modeling alternative", () => { + for (const study of petriconStudies.filter( + (candidate) => candidate.category === "Modeling", + )) { + expect(study.names).toHaveLength(3); + expect(study.variants).toHaveLength(study.names.length); + for (const name of study.names) { + const { container, unmount } = render( + , + ); + expect(getExperimentalIconEffects(name)).toContain("draw"); + const paths = container.querySelectorAll("[data-icon-draw]"); + expect(paths.length).toBeGreaterThan(1); + for (const path of paths) + expect(path.getAttribute("pathLength")).toBe("1"); + unmount(); + } + } + }); + + it("hands the cube from hover to click with one frame loop and stops for reduced motion", () => { + const frames = new Map(); + let frameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); + const tick = (time: number) => + act(() => { + const pending = [...frames.values()]; + frames.clear(); + pending.forEach((callback) => callback(time)); + }); + const Sample = ({ + trigger = 0, + motion = "auto", + }: { + trigger?: number; + motion?: "auto" | "none"; + }) => ( + + ); + const { container, rerender } = render(); + const cube = container.querySelector("[data-icon-cube]"); + fireEvent.pointerEnter(screen.getByRole("button")); + tick(0); + tick(100); + const hoverAngle = Number(cube?.getAttribute("data-cube-angle")); + expect(hoverAngle).toBeGreaterThan(35); + expect(hoverAngle).toBeLessThan(55); + rerender(); + expect(frames.size).toBe(1); + tick(110); + tick(250); + expect(Number(cube?.getAttribute("data-cube-angle"))).toBeGreaterThan( + hoverAngle, + ); + expect(cube?.querySelectorAll('[visibility="visible"]')).toHaveLength(9); + rerender(); + expect(frames.size).toBe(0); + expect(cube?.getAttribute("data-cube-angle")).toBe("35"); + }); + + it("clamps drawing progress, preserves it with motion disabled, and releases the override", () => { + const { container, rerender } = render( + , + ); + const path = container.querySelector("[data-icon-draw]"); + expect(path?.style.strokeDashoffset).toBe("0.6"); + rerender( + , + ); + expect(path?.style.opacity).toBe("0"); + rerender(); + expect(path?.style.strokeDashoffset).toBe("0"); + rerender(); + expect(path?.style.strokeDasharray).toBe(""); + expect(path?.style.fillOpacity).toBe(""); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons.tsx new file mode 100644 index 00000000000..9dd69301491 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons.tsx @@ -0,0 +1,704 @@ +import { createContext, use } from "react"; + +import { IconProvider, type IconPack } from "@hashintel/ds-components"; + +import { AddNodeGeometry } from "./experimental-icons/add-node-geometry"; +import { additionalGeometry } from "./experimental-icons/additional-geometry"; +import { explorationGeometry } from "./experimental-icons/exploration-geometry"; +import { IconInteraction } from "./experimental-icons/interaction"; +import { modelingGeometry } from "./experimental-icons/modeling-geometry"; +import { + experimentalIconEffects, + getIconTransition, + resolveIconDuration, + useIconEffects, + useIconMotionAllowed, +} from "./experimental-icons/motion"; +import { ParameterGeometry } from "./experimental-icons/parameter-geometry"; +import { SettingsGeometry } from "./experimental-icons/settings-geometry"; +import { ShapesGeometry } from "./experimental-icons/shapes-geometry"; +import { IconMotionLayer } from "./experimental-icons/shared/icon-motion-layer"; +import { SidebarGeometry } from "./experimental-icons/sidebar-geometry"; +import { + FlaskGeometry, + LayerGeometry, +} from "./experimental-icons/simulation-geometry"; +import { + DiagnosticsGeometry, + MenuGeometry, + MicrophoneGeometry, + PauseGeometry, + PlaybackGeometry, +} from "./experimental-icons/state-geometry"; +import { utilityGeometry } from "./experimental-icons/utility-geometry"; + +import type { ExperimentalIconBadgeVisibility } from "./experimental-icons/add-node-geometry"; +import type { + ExperimentalIconMotion, + ExperimentalIconTransition, + IconMotionProps, +} from "./experimental-icons/motion"; +import type { ExperimentalIconStatus } from "./experimental-icons/state-geometry"; +import type { PropsWithChildren, SVGProps } from "react"; + +export { petriconHints } from "./experimental-icons/shared/petricon-hints"; + +export { experimentalIconEffects } from "./experimental-icons/motion"; +export type { ExperimentalIconStatus } from "./experimental-icons/state-geometry"; +export type { ExperimentalIconBadgeVisibility } from "./experimental-icons/add-node-geometry"; +export type { + ExperimentalIconEffect, + ExperimentalIconMotion, + ExperimentalIconTransition, + ExperimentalIconChoreography, +} from "./experimental-icons/motion"; + +export type ExperimentalIconVariant = "outline" | "filled"; + +export type ExperimentalIconDefaults = { + size?: number; + /** Stroke weight from 100 to 700, with 400 as the default. */ + weight?: number; + color?: string; + motion?: ExperimentalIconMotion; + duration?: number; +}; + +const ExperimentalIconContext = createContext< + ExperimentalIconDefaults & { enabled?: boolean } +>({}); + +export const useExperimentalIconPackEnabled = () => + use(ExperimentalIconContext).enabled ?? false; + +export const useExperimentalIconMotionAllowed = () => + useIconMotionAllowed(use(ExperimentalIconContext).motion ?? "auto"); + +const searchGeometry = ( + + + + +); + +const geometry = { + ...additionalGeometry, + ...utilityGeometry, + ...explorationGeometry, + sidebar: null, + menu: null, + microphone: null, + playback: null, + diagnostics: null, + loading: ( + + + + + ), + hand: ( + <> + + + + ), + cursor: , + place: , + transition: , + addPlace: null, + addTransition: null, + settings: null, + parameter: null, + function: ( + + ), + play: , + pause: , + stepForward: ( + <> + + + + ), + stop: , + plus: , + minus: , + close: , + check: , + search: searchGeometry, + zoomIn: ( + <> + {searchGeometry} + + + ), + zoomOut: ( + <> + {searchGeometry} + + + ), + fitView: , + chevronUp: , + chevronDown: , + chevronLeft: , + chevronRight: , + ...modelingGeometry, +} as const; + +export type ExperimentalIconName = keyof typeof geometry; + +const stateTransitionIcons: readonly ExperimentalIconName[] = [ + "addPlace", + "addTransition", + "sidebar", + "settings", + "flask", + "layer", + "shapes", + "diagnostics", +]; + +export const getExperimentalIconEffects = (name: ExperimentalIconName) => + experimentalIconEffects.filter((effect) => { + if (effect === "action") return !stateTransitionIcons.includes(name); + if (effect === "draw") + return ( + name === "addPlace" || + name === "addTransition" || + name === "parameter" || + name in modelingGeometry + ); + return true; + }); + +export const experimentalIconNames = Object.keys( + geometry, +) as ExperimentalIconName[]; + +export type ExperimentalIconProps = ExperimentalIconDefaults & + IconMotionProps & + Omit, "children" | "name"> & { + name: ExperimentalIconName; + variant?: ExperimentalIconVariant; + selected?: boolean; + collapsed?: boolean; + open?: boolean; + playing?: boolean; + muted?: boolean; + status?: ExperimentalIconStatus; + hover?: "auto" | "none"; + interaction?: "auto" | "none"; + badge?: ExperimentalIconBadgeVisibility; + transition?: ExperimentalIconTransition; + }; + +export const ExperimentalIcon = ({ + name, + size, + weight, + color, + variant = "outline", + strokeWidth, + effect, + trigger, + active, + duration, + choreography, + drawProgress, + motion, + selected = false, + collapsed = false, + open = false, + playing = false, + muted = false, + status = "valid", + hover = "auto", + interaction = "auto", + badge = "hover", + transition = "smooth", + ...props +}: ExperimentalIconProps) => { + const defaults = use(ExperimentalIconContext); + const motionAllowed = useIconMotionAllowed( + motion ?? defaults.motion ?? "auto", + ); + const resolvedDuration = resolveIconDuration( + duration ?? defaults.duration ?? 600, + ); + const drawing = + drawProgress !== undefined || + (typeof effect === "string" ? effect === "draw" : effect?.includes("draw")); + const hoverAllowed = motionAllowed && hover === "auto" && !drawing; + const motionRef = useIconEffects({ + effect, + trigger, + active, + duration: resolvedDuration, + choreography, + allowed: motionAllowed, + identity: `${name}:${variant}`, + name, + interactive: interaction === "auto" && !drawing, + authoredHover: hoverAllowed && name in modelingGeometry, + drawProgress, + }); + const isAddIcon = name === "addPlace" || name === "addTransition"; + const resolvedTransition = getIconTransition( + motionAllowed, + transition, + resolvedDuration, + ); + const simulationTransition = getIconTransition( + motionAllowed, + transition, + Math.min(resolvedDuration, 300), + ); + const resolvedSize = size ?? defaults.size ?? 24; + const requestedWeight = weight ?? defaults.weight ?? 400; + const resolvedWeight = Number.isFinite(requestedWeight) + ? Math.min(700, Math.max(100, requestedWeight)) + : 400; + const resolvedStrokeWidth = strokeWidth ?? 1 + (resolvedWeight - 100) / 300; + const labelled = Boolean(props["aria-label"] || props["aria-labelledby"]); + + return ( + + + {isAddIcon ? ( + + ) : name === "sidebar" ? ( + + ) : name === "settings" ? ( + + ) : name === "flask" ? ( + + ) : name === "layer" ? ( + + ) : name === "shapes" ? ( + + ) : ( + + + {name === "diagnostics" ? ( + + ) : name === "menu" ? ( + + ) : name === "microphone" ? ( + + ) : name === "playback" ? ( + + ) : name === "parameter" ? ( + + ) : ( + + {geometry[name]} + + )} + + + )} + + + ); +}; + +export type ExperimentalIconComponentProps = Omit< + ExperimentalIconProps, + "name" +>; + +const createIcon = ( + name: ExperimentalIconName, + variant: ExperimentalIconVariant = "outline", +) => { + const IconComponent = (props: ExperimentalIconComponentProps) => ( + + ); + return IconComponent; +}; + +export const PlaceIcon = createIcon("place"); +export const SidebarIcon = createIcon("sidebar"); +export const MenuIcon = createIcon("menu"); +export const HandIcon = createIcon("hand"); +export const CursorIcon = createIcon("cursor"); +export const AddPlaceIcon = createIcon("addPlace"); +export const AddTransitionIcon = createIcon("addTransition"); +export const SettingsIcon = createIcon("settings"); +export const TransitionIcon = createIcon("transition"); +export const TokenTypeIcon = createIcon("tokenType"); +export const ParameterIcon = createIcon("parameter"); +export const FunctionIcon = createIcon("function"); +export const PlayIcon = createIcon("play"); +export const PauseIcon = createIcon("pause"); +export const StepForwardIcon = createIcon("stepForward"); +export const StopIcon = createIcon("stop"); +export const ResetIcon = createIcon("reset"); +export const PlusIcon = createIcon("plus"); +export const MinusIcon = createIcon("minus"); +export const CloseIcon = createIcon("close"); +export const CheckIcon = createIcon("check"); +export const SearchIcon = createIcon("search"); +export const ZoomInIcon = createIcon("zoomIn"); +export const ZoomOutIcon = createIcon("zoomOut"); +export const FitViewIcon = createIcon("fitView"); +export const ChevronUpIcon = createIcon("chevronUp"); +export const ChevronDownIcon = createIcon("chevronDown"); +export const ChevronLeftIcon = createIcon("chevronLeft"); +export const ChevronRightIcon = createIcon("chevronRight"); + +export const InfinityIcon = createIcon("infinity"); +export const ArrowUpIcon = createIcon("arrowUp"); +export const ArrowDownIcon = createIcon("arrowDown"); +export const ArrowLeftIcon = createIcon("arrowLeft"); +export const ArrowRightIcon = createIcon("arrowRight"); +export const ArrowUpRightIcon = createIcon("arrowUpRight"); +export const ArrowsLeftRightIcon = createIcon("arrowsLeftRight"); +export const TrashIcon = createIcon("trash"); +export const PencilIcon = createIcon("pencil"); +export const CopyIcon = createIcon("copy"); +export const DownloadIcon = createIcon("download"); +export const ExternalLinkIcon = createIcon("externalLink"); +export const EllipsisIcon = createIcon("ellipsis"); +export const EllipsisVerticalIcon = createIcon("ellipsisVertical"); +export const CircleEllipsisIcon = createIcon("circleEllipsis"); +export const CollapseIcon = createIcon("collapse"); +export const LockOpenIcon = createIcon("lockOpen"); +export const LockClosedIcon = createIcon("lockClosed"); +export const EyeIcon = createIcon("eye"); +export const EyeSlashIcon = createIcon("eyeSlash"); +export const ClockIcon = createIcon("clock"); +export const ClockRotateLeftIcon = createIcon("clockRotateLeft"); +export const RotateIcon = createIcon("rotate"); +export const RightToLineIcon = createIcon("rightToLine"); +export const FlaskIcon = createIcon("flask"); +export const LayerIcon = createIcon("layer"); +export const ChartBarSimpleIcon = createIcon("chartBarSimple"); +export const ChartLineIcon = createIcon("chartLine"); +export const FilterIcon = createIcon("filter"); +export const BracketsCurlyIcon = createIcon("bracketsCurly"); +export const LambdaIcon = createIcon("lambda"); +export const LightningIcon = createIcon("lightning"); +export const CodeIcon = createIcon("code"); +export const CubeIcon = createIcon("cube"); +export const DiagramNestedIcon = createIcon("diagramNested"); +export const DiagramProjectIcon = createIcon("diagramProject"); +export const ShapesIcon = createIcon("shapes"); +export const FileIcon = createIcon("file"); +export const FileLinesIcon = createIcon("fileLines"); +export const ListIcon = createIcon("list"); +export const TableIcon = createIcon("table"); +export const GridIcon = createIcon("grid"); +export const BullseyeIcon = createIcon("bullseye"); +export const ScribbleIcon = createIcon("scribble"); +export const SparklesIcon = createIcon("sparkles"); +export const InfoIcon = createIcon("info"); +export const ErrorIcon = createIcon("error"); +export const WarningIcon = createIcon("warning"); +export const CircleCheckIcon = createIcon("circleCheck"); +export const TerminalIcon = createIcon("terminal"); +export const TextIcon = createIcon("text"); +export const UserIcon = createIcon("user"); +export const VoiceIcon = createIcon("voice"); +export const TranscriptionIcon = createIcon("transcription"); +export const AssistantIcon = createIcon("assistant"); +export const MicrophoneIcon = createIcon("microphone"); +export const PlaybackIcon = createIcon("playback"); +export const DiagnosticsIcon = createIcon("diagnostics"); +export const LoadingIcon = (props: ExperimentalIconComponentProps) => ( + +); +export const ArrowTrendDownIcon = createIcon("arrowTrendDown"); +export const ArrowTrendUpIcon = createIcon("arrowTrendUp"); +export const AsteriskIcon = createIcon("asterisk"); +export const AtIcon = createIcon("at"); +export const BarcodeIcon = createIcon("barcode"); +export const BellIcon = createIcon("bell"); +export const BracketsSquareIcon = createIcon("bracketsSquare"); +export const BugIcon = createIcon("bug"); +export const CalendarIcon = createIcon("calendar"); +export const CalendarClockIcon = createIcon("calendarClock"); +export const CircleOneIcon = createIcon("circleOne"); +export const CubesIcon = createIcon("cubes"); +export const DiagramNodesIcon = createIcon("diagramNodes"); +export const DiagramSubtaskIcon = createIcon("diagramSubtask"); +export const DiamondExclamationIcon = createIcon("diamondExclamation"); +export const EmptySetIcon = createIcon("emptySet"); +export const FeatherIcon = createIcon("feather"); +export const FileSpreadsheetIcon = createIcon("fileSpreadsheet"); +export const GripVerticalIcon = createIcon("gripVertical"); +export const ImageIcon = createIcon("image"); +export const InputPipeIcon = createIcon("inputPipe"); +export const LightbulbOnIcon = createIcon("lightbulbOn"); +export const ListTreeIcon = createIcon("listTree"); +export const MagicIcon = createIcon("magic"); +export const MemoCircleCheckIcon = createIcon("memoCircleCheck"); +export const MicroscopeIcon = createIcon("microscope"); +export const OneHundredIcon = createIcon("oneHundred"); +export const PersonRunningIcon = createIcon("personRunning"); +export const PlugIcon = createIcon("plug"); +export const PrintIcon = createIcon("print"); +export const PuzzlePieceIcon = createIcon("puzzlePiece"); +export const RulerIcon = createIcon("ruler"); +export const SortDownIcon = createIcon("sortDown"); +export const SortDownAZIcon = createIcon("sortDownAZ"); +export const SortUpIcon = createIcon("sortUp"); +export const SortUpAZIcon = createIcon("sortUpAZ"); +export const SquareCheckIcon = createIcon("squareCheck"); +export const StarIcon = createIcon("star"); +export const ThoughtBubbleIcon = createIcon("thoughtBubble"); +export const TruckIcon = createIcon("truck"); +export const UserPlusIcon = createIcon("userPlus"); + +export const experimentalIconPack: Required = { + loadingSpinner: LoadingIcon, + arrowTrendDown: ArrowTrendDownIcon, + arrowTrendUp: ArrowTrendUpIcon, + asterisk: AsteriskIcon, + at: AtIcon, + barcode: BarcodeIcon, + bell: BellIcon, + bracketsSquare: BracketsSquareIcon, + bug: BugIcon, + calendar: CalendarIcon, + calendarClock: CalendarClockIcon, + circleOne: CircleOneIcon, + cubes: CubesIcon, + diagramNodes: DiagramNodesIcon, + diagramSubtask: DiagramSubtaskIcon, + diamondExclamation: DiamondExclamationIcon, + emptySet: EmptySetIcon, + feather: FeatherIcon, + fileSpreadsheet: FileSpreadsheetIcon, + gripVertical: GripVerticalIcon, + image: ImageIcon, + inputPipe: InputPipeIcon, + lightbulbOn: LightbulbOnIcon, + listTree: ListTreeIcon, + magic: MagicIcon, + memoCircleCheck: MemoCircleCheckIcon, + microscope: MicroscopeIcon, + oneHundred: OneHundredIcon, + personRunning: PersonRunningIcon, + plug: PlugIcon, + print: PrintIcon, + puzzlePiece: PuzzlePieceIcon, + ruler: RulerIcon, + sortDown: SortDownIcon, + sortDownAZ: SortDownAZIcon, + sortUp: SortUpIcon, + sortUpAZ: SortUpAZIcon, + squareCheck: SquareCheckIcon, + star: StarIcon, + thoughtBubble: ThoughtBubbleIcon, + truck: TruckIcon, + userPlus: UserPlusIcon, + starFilled: createIcon("star", "filled"), + infinity: InfinityIcon, + arrowUp: ArrowUpIcon, + arrowDown: ArrowDownIcon, + arrowLeft: ArrowLeftIcon, + arrowRight: ArrowRightIcon, + arrowUpRight: ArrowUpRightIcon, + arrowsLeftRight: ArrowsLeftRightIcon, + trash: TrashIcon, + pencil: PencilIcon, + copy: CopyIcon, + download: DownloadIcon, + externalLink: ExternalLinkIcon, + ellipsis: EllipsisIcon, + ellipsisVertical: EllipsisVerticalIcon, + circleEllipsis: CircleEllipsisIcon, + collapse: CollapseIcon, + lockOpen: LockOpenIcon, + lockClosed: LockClosedIcon, + eye: EyeIcon, + eyeSlash: EyeSlashIcon, + clock: ClockIcon, + clockRotateLeft: ClockRotateLeftIcon, + rotate: RotateIcon, + rightToLine: RightToLineIcon, + flask: FlaskIcon, + layer: LayerIcon, + chartBarSimple: ChartBarSimpleIcon, + chartLine: ChartLineIcon, + filter: FilterIcon, + bracketsCurly: BracketsCurlyIcon, + lambda: LambdaIcon, + lightning: LightningIcon, + code: CodeIcon, + cube: CubeIcon, + diagramNested: DiagramNestedIcon, + diagramProject: DiagramProjectIcon, + shapes: ShapesIcon, + file: FileIcon, + fileLines: FileLinesIcon, + list: ListIcon, + table: TableIcon, + grid: GridIcon, + bullseye: BullseyeIcon, + scribble: ScribbleIcon, + sparkles: SparklesIcon, + info: InfoIcon, + error: ErrorIcon, + warning: WarningIcon, + circleCheck: CircleCheckIcon, + terminal: TerminalIcon, + text: TextIcon, + user: UserIcon, + edit: PencilIcon, + chartBar: ChartBarSimpleIcon, + refresh: RotateIcon, + caretDown: ChevronDownIcon, + chevronDownHeavy: ChevronDownIcon, + chevronUpHeavy: ChevronUpIcon, + chevronLeftHeavy: ChevronLeftIcon, + chevronRightHeavy: ChevronRightIcon, + sidebar: SidebarIcon, + bars: MenuIcon, + hand: HandIcon, + cursor: CursorIcon, + circlePlus: AddPlaceIcon, + squarePlus: AddTransitionIcon, + gear: SettingsIcon, + circle: PlaceIcon, + circleFilled: createIcon("place", "filled"), + square: TransitionIcon, + squareFilled: createIcon("transition", "filled"), + threeCircles: TokenTypeIcon, + sliders: ParameterIcon, + function: FunctionIcon, + play: PlayIcon, + playFilled: createIcon("play", "filled"), + pause: PauseIcon, + pauseFilled: createIcon("pause", "filled"), + skipForward: StepForwardIcon, + skipForwardFilled: createIcon("stepForward", "filled"), + stop: StopIcon, + stopFilled: createIcon("stop", "filled"), + rotateLeft: ResetIcon, + undo: ResetIcon, + plus: PlusIcon, + dash: MinusIcon, + close: CloseIcon, + check: CheckIcon, + search: SearchIcon, + zoomIn: ZoomInIcon, + zoomOut: ZoomOutIcon, + expand: FitViewIcon, + chevronUp: ChevronUpIcon, + chevronDown: ChevronDownIcon, + chevronLeft: ChevronLeftIcon, + chevronRight: ChevronRightIcon, +}; + +const defaultIconPack: IconPack = {}; + +export const ExperimentalIconProvider = ({ + enabled = true, + children, + ...defaults +}: PropsWithChildren) => { + const parentDefaults = use(ExperimentalIconContext); + return ( + + + {children} + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/add-node-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/add-node-geometry.tsx new file mode 100644 index 00000000000..7115ba937a3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/add-node-geometry.tsx @@ -0,0 +1,123 @@ +import { useId } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { IconMotionLayer } from "./shared/icon-motion-layer"; + +export type ExperimentalIconBadgeVisibility = "hover" | "visible" | "hidden"; + +const badgeVisibilityStyle = css({ + "--add-icon-badge-opacity": "0", + "--add-icon-badge-scale": "0.7", + '&[data-badge="visible"], svg:hover &[data-badge="hover"], button:hover &[data-badge="hover"]': + { + "--add-icon-badge-opacity": "1", + "--add-icon-badge-scale": "1", + }, +}); + +export const AddNodeGeometry = ({ + kind, + filled, + selected, + badge, + transition, +}: { + kind: "place" | "transition"; + filled: boolean; + selected: boolean; + badge: ExperimentalIconBadgeVisibility; + transition: string; +}) => { + const id = useId(); + const cutoutId = `${id}-cutout`; + const revealId = `${id}-reveal`; + const frameStyle = { rx: kind === "place" ? "8px" : "2px", transition }; + const symbolStyle = { + opacity: "var(--add-icon-badge-opacity)", + transform: "scale(var(--add-icon-badge-scale))", + transformOrigin: "19px 19px", + transition, + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/additional-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/additional-geometry.tsx new file mode 100644 index 00000000000..e17e431b4af --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/additional-geometry.tsx @@ -0,0 +1,268 @@ +const documentFrame = ; +const clockFace = ; +const eyeFrame = ; + +export const additionalGeometry = { + infinity: ( + + ), + arrowUp: , + arrowDown: , + arrowLeft: , + arrowRight: , + arrowUpRight: , + arrowsLeftRight: , + trash: ( + <> + + + + ), + pencil: ( + + ), + copy: ( + <> + + + + ), + download: ( + <> + + + + ), + externalLink: ( + <> + + + + ), + ellipsis: ( + + + + + + ), + ellipsisVertical: ( + + + + + + ), + circleEllipsis: ( + <> + + + + ), + collapse: ( + + ), + lockOpen: ( + <> + + + + + ), + lockClosed: ( + <> + + + + + ), + eye: ( + <> + {eyeFrame} + + + ), + eyeSlash: ( + <> + {eyeFrame} + + + + ), + clock: ( + <> + {clockFace} + + + ), + clockRotateLeft: ( + <> + + + + ), + rotate: ( + + ), + rightToLine: ( + <> + + + + ), + flask: null, + layer: null, + chartBarSimple: ( + <> + + + + ), + chartLine: ( + <> + + + + ), + filter: , + bracketsCurly: ( + <> + + + + ), + lambda: , + lightning: , + code: ( + <> + + + + + ), + cube: , + diagramNested: ( + <> + + + + ), + diagramProject: ( + <> + + + + + + ), + shapes: null, + file: documentFrame, + fileLines: ( + <> + {documentFrame} + + + ), + list: ( + <> + + + + ), + table: ( + <> + + + + ), + grid: ( + <> + + + + + + ), + bullseye: ( + <> + + + + + ), + scribble: ( + <> + + + + ), + sparkles: ( + <> + + + + ), + info: ( + <> + + + + ), + error: ( + <> + + + + ), + warning: ( + <> + + + + ), + circleCheck: ( + <> + + + + ), + terminal: ( + <> + + + + + ), + text: , + user: ( + <> + + + + ), + voice: ( + + ), + transcription: , + assistant: ( + + + + ), +} as const; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/exploration-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/exploration-geometry.tsx new file mode 100644 index 00000000000..e2c891b7dd9 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/exploration-geometry.tsx @@ -0,0 +1,204 @@ +const matrixBrackets = ; + +export const explorationGeometry = { + sum: , + sumRange: ( + <> + + + + ), + integral: ( + + ), + integralBounds: ( + <> + + + + ), + matrix: ( + <> + {matrixBrackets} + + + + + + + + ), + matrixGrid: ( + <> + {matrixBrackets} + + + ), + probability: ( + <> + + + + + ), + probabilityDice: ( + <> + + + + + + + + + + ), + expression: ( + <> + + + + + ), + expressionTree: ( + <> + + + + + + ), + typeVariable: ( + <> + + + + ), + typeUnion: ( + <> + + + + + ), + breakpoint: ( + <> + + + + ), + breakpointLine: ( + <> + + + + + ), + testTube: ( + <> + + + + + ), + testCheck: ( + <> + + + + ), + agent: ( + <> + + + + + + + + ), + agentOrbit: ( + <> + + + + + + + + ), + context: ( + <> + + + + + ), + contextWindow: ( + <> + + + + + ), + reasoning: ( + <> + + + + + + ), + reasoningFork: ( + <> + + + + + + ), + embedding: ( + <> + + + + + + + + + + ), + embeddingVector: ( + <> + + + + + ), +} as const; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/interaction.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/interaction.tsx new file mode 100644 index 00000000000..acb3e56b59e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/interaction.tsx @@ -0,0 +1,129 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import { petriconHints } from "./shared/petricon-hints"; + +import type { ExperimentalIconName } from "../experimental-icons"; +import type { PropsWithChildren } from "react"; + +const interactionStyle = css({ + transformOrigin: "[12px 12px]", + "& g": { transition: "[inherit]" }, + "& [data-icon-detail]": { + transformOrigin: "[12px 12px]", + transition: "[inherit]", + animationDuration: "[var(--petricon-detail-duration)]", + animationTimingFunction: "[cubic-bezier(0.2, 0, 0.2, 1)]", + animationIterationCount: "[1]", + }, + '&[data-hint="sequence"] > g > g > *': { + transformOrigin: "[12px 12px]", + animationDuration: "[var(--petricon-detail-duration)]", + animationTimingFunction: "[ease-out]", + "&:nth-child(2)": { animationDelay: "[35ms]" }, + "&:nth-child(3)": { animationDelay: "[70ms]" }, + "&:nth-child(4)": { animationDelay: "[105ms]" }, + }, + "& [data-icon-detail=eyes]": { transformOrigin: "[12px 13px]" }, + "& [data-icon-detail=scan-bar]": { transformOrigin: "[12px 20px]" }, + "& [data-icon-detail=lid]": { transformOrigin: "[5px 6px]" }, + "& [data-icon-detail=bars]": { transformOrigin: "[12px 19px]" }, + "& [data-icon-detail=calendar-day]": { transformOrigin: "[9.5px 15.5px]" }, + "& [data-icon-detail=calendar-hand]": { transformOrigin: "[16px 16px]" }, + "& [data-icon-detail=tag-token]": { transformOrigin: "[13.5px 13.5px]" }, + '&[data-hover="true"]:is(button:is(:hover, :focus-visible) *, a[href]:is(:hover, :focus-visible) *, [role="tab"]:hover *, [role="menuitem"][data-highlighted] *, [data-icon-preview]:hover *, label[data-scope="checkbox"]:is(:hover, [data-focus-visible]) *, [data-scope="segment-group"][data-part="item"]:is(:hover, [data-focus-visible]) *):not(:is(button:disabled *, [aria-disabled="true"] *, [data-disabled] *))': + { + '&[data-hint="right"]': { transform: "[translateX(1px)]" }, + '&[data-hint="left"]': { transform: "[translateX(-1px)]" }, + '&[data-hint="up"], &[data-hint="lift"]': { + transform: "[translateY(-1px)]", + }, + '&[data-hint="down"]': { transform: "[translateY(1px)]" }, + '&[data-hint="out"]': { transform: "[translate(0.7px, -0.7px)]" }, + '&[data-hint="tilt"]': { transform: "[rotate(-6deg)]" }, + '&[data-hint="rewind"]': { transform: "[rotate(-12deg)]" }, + '&[data-hint="turn"]': { transform: "[rotate(12deg)]" }, + '&[data-hint="grow"]': { transform: "[scale(1.06)]" }, + '&[data-hint="shrink"]': { transform: "[scale(0.94)]" }, + '&[data-hint="sequence"] > g > g > *': { + animationName: "[petriconSequence]", + }, + "& [data-icon-detail=open-left]": { transform: "[translateX(-0.8px)]" }, + "& [data-icon-detail=open-right]": { transform: "[translateX(0.8px)]" }, + "& [data-icon-detail=notation], & [data-icon-detail=type-mark]": { + animationName: "[petriconNotation]", + }, + "& [data-icon-detail=bounds]": { transform: "[scaleY(1.08)]" }, + "& [data-icon-detail=row], & [data-icon-detail=connection]": { + animationName: "[petriconNotation]", + }, + "& [data-icon-detail=cells], & [data-icon-detail=node], & [data-icon-detail=test-result]": + { animationName: "[petriconSignal]" }, + "& [data-icon-detail=distribution], & [data-icon-detail=liquid-level]": { + animationName: "[petriconDistribution]", + }, + "& [data-icon-detail=orbit], & [data-icon-detail=antenna]": { + animationName: "[petriconOrbit]", + }, + "& [data-icon-detail=eyes]": { animationName: "[petriconBlink]" }, + "& [data-icon-detail=scan-bar]": { animationName: "[petriconScan]" }, + "& [data-icon-detail=lid]": { + transform: "[translateY(-1px) rotate(-6deg)]", + }, + "& [data-icon-detail=sheet]": { transform: "[translate(0.7px, 0.7px)]" }, + "& [data-icon-detail=down]": { transform: "[translateY(1px)]" }, + "& [data-icon-detail=right]": { transform: "[translateX(1px)]" }, + "& [data-icon-detail=out]": { transform: "[translate(0.7px, -0.7px)]" }, + "& [data-icon-detail=dot]": { transform: "[scale(1.2)]" }, + "& [data-icon-detail=shackle]": { transform: "[translateY(-0.7px)]" }, + "& [data-icon-detail=pupil]": { transform: "[scale(1.15)]" }, + "& [data-icon-detail=clock-hand]": { transform: "[rotate(-12deg)]" }, + "& [data-icon-detail=bars]": { transform: "[scaleY(1.08)]" }, + "& [data-icon-detail=line-chart]": { transform: "[translateY(-0.7px)]" }, + "& [data-icon-detail=lines]": { transform: "[translateX(0.7px)]" }, + "& [data-icon-detail=target]": { transform: "[scale(0.9)]" }, + "& [data-icon-detail=sparkle]": { + transform: "[scale(1.06) rotate(4deg)]", + }, + "& [data-icon-detail=wave]": { transform: "[scaleY(0.85)]" }, + "& [data-icon-detail=bell-clapper]": { transform: "[translateX(0.7px)]" }, + "& [data-icon-detail=calendar-day]": { transform: "[scale(1.1)]" }, + "& [data-icon-detail=calendar-hand]": { transform: "[rotate(-12deg)]" }, + "& [data-icon-detail=filament]": { transform: "[translateY(-0.7px)]" }, + "& [data-icon-detail=slider]": { + animationName: "[petrinautIconParameterScrub]", + }, + }, + '&[data-hover="true"]:is(button:active *, a[href]:active *, [data-scope="segment-group"][data-part="item"]:active *):not(:is(button:disabled *, [aria-disabled="true"] *, [data-disabled] *))': + { + transform: "[scale(0.94)]", + }, +}); + +export const IconInteraction = ({ + name, + enabled, + transition, + duration, + children, +}: PropsWithChildren<{ + name: ExperimentalIconName; + enabled: boolean; + transition: string; + duration: number; +}>) => ( + + + {children} + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/modeling-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/modeling-geometry.tsx new file mode 100644 index 00000000000..f5c2a6c97c2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/modeling-geometry.tsx @@ -0,0 +1,219 @@ +import { projectCube } from "./shared/cube-projection"; + +import type { SVGProps } from "react"; + +const Trace = ({ + layer = "symbol", + ...props +}: SVGProps & { layer?: string }) => ( + +); +const Ring = ({ + layer = "symbol", + ...props +}: SVGProps & { layer?: string }) => ( + +); +const rewindArc = ( + + + + +); + +export const modelingGeometry = { + clockRotateLeft: ( + <> + {rewindArc} + + + + + ), + reset: rewindArc, + cube: ( + + {projectCube(35).map((edge) => ( + + ))} + + ), + differentialEquation: ( + <> + + + + + + + + + + ), + equationCurve: ( + <> + + + + + + + ), + equationFlow: ( + <> + + + + + + ), + parameterDial: ( + <> + + + + + + + + ), + parameterRange: ( + <> + + + + + + + + + + ), + variable: ( + <> + + + + + + + + ), + variableBrackets: ( + <> + + + + + + + ), + variableRegister: ( + <> + + + + + + + ), + tokenType: ( + <> + + + + + + + + + + + ), + tokenTypeStack: ( + <> + + + + + + + ), + tokenTypeTag: ( + <> + + + + + + + ), + subnetNetwork: ( + <> + + + + + + + + + ), + subnetLayers: ( + <> + + + + + + + ), +} as const; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion.tsx new file mode 100644 index 00000000000..d53fdc4b940 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion.tsx @@ -0,0 +1,341 @@ +/** + * @layerRoot ui.petricon + * @role Petricon SVG geometry, configurable effects, and state transitions + */ +import { + useEffect, + useLayoutEffect, + useRef, + useSyncExternalStore, +} from "react"; + +import { + iconControlSelector, + isIconControlDisabled, + playIconAction, +} from "./motion/action-motion"; +import { animateCube, paintCube } from "./shared/cube-projection"; + +import type { ExperimentalIconName } from "../experimental-icons"; + +export const experimentalIconEffects = [ + "action", + "bounce", + "pulse", + "rotate", + "draw", +] as const; +export type ExperimentalIconEffect = (typeof experimentalIconEffects)[number]; +export type ExperimentalIconTransition = "none" | "smooth" | "spring"; +export type ExperimentalIconMotion = "auto" | "none"; +export type ExperimentalIconChoreography = + | "together" + | "stagger" + | "sequential"; + +export type IconMotionProps = { + effect?: ExperimentalIconEffect | readonly ExperimentalIconEffect[]; + trigger?: string | number; + active?: boolean; + duration?: number; + choreography?: ExperimentalIconChoreography; + drawProgress?: number; +}; + +const reducedMotionQuery = "(prefers-reduced-motion: reduce)"; +const getMotionMedia = (): MediaQueryList | undefined => + typeof window.matchMedia === "function" + ? window.matchMedia(reducedMotionQuery) + : undefined; +const subscribeToReducedMotion = (onChange: () => void) => { + const media = getMotionMedia(); + media?.addEventListener("change", onChange); + return () => media?.removeEventListener("change", onChange); +}; +const getReducedMotion = () => getMotionMedia()?.matches ?? false; +const getServerReducedMotion = () => true; + +export const useIconMotionAllowed = (motion: ExperimentalIconMotion) => { + const reducedMotion = useSyncExternalStore( + subscribeToReducedMotion, + getReducedMotion, + getServerReducedMotion, + ); + return motion !== "none" && !reducedMotion; +}; + +export const resolveIconDuration = (duration: number) => + Number.isFinite(duration) ? Math.min(10000, Math.max(100, duration)) : 600; + +export const getIconTransition = ( + allowed: boolean, + transition: ExperimentalIconTransition, + duration: number, +) => { + if (!allowed || transition === "none") { + return "none"; + } + const easing = + transition === "spring" + ? "cubic-bezier(0.22, 1.35, 0.36, 1)" + : "cubic-bezier(0.2, 0, 0.2, 1)"; + return [ + "transform", + "opacity", + "rx", + "stroke-dashoffset", + "stroke-dasharray", + "color", + ] + .map((property) => `${property} ${duration}ms ${easing}`) + .join(", "); +}; + +const effectKeyframes: Record< + Exclude, + Keyframe[] +> = { + bounce: [ + { transform: "translateY(0px) scale(1)" }, + { transform: "translateY(1px) scale(0.88)", offset: 0.2 }, + { transform: "translateY(-1.5px) scale(1.08)", offset: 0.5 }, + { transform: "translateY(0px) scale(1)" }, + ], + pulse: [{ opacity: 1 }, { opacity: 0.3 }, { opacity: 1 }], + rotate: [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], +}; + +export const useIconEffects = ({ + effect, + trigger, + active, + duration = 600, + choreography = "stagger", + allowed, + identity, + name, + interactive, + authoredHover, + drawProgress, +}: IconMotionProps & { + allowed: boolean; + identity: string; + name: ExperimentalIconName; + interactive: boolean; + authoredHover: boolean; +}) => { + const ref = useRef(null); + const previousTrigger = useRef(trigger); + const cancelInteraction = useRef<() => void>(() => {}); + const effectKey = ( + typeof effect === "string" ? [effect] : (effect ?? []) + ).join(","); + + useLayoutEffect(() => { + const targets = + ref.current?.querySelectorAll("[data-icon-draw]"); + if (drawProgress === undefined || !targets) return; + const progress = Number.isFinite(drawProgress) + ? Math.max(0, Math.min(1, drawProgress)) + : 1; + for (const target of targets) { + target.style.strokeDasharray = "1 1"; + target.style.strokeDashoffset = String(1 - progress); + target.style.opacity = progress === 0 ? "0" : ""; + target.style.fillOpacity = String(progress); + } + return () => { + for (const target of targets) { + target.style.strokeDasharray = ""; + target.style.strokeDashoffset = ""; + target.style.opacity = ""; + target.style.fillOpacity = ""; + } + }; + }, [drawProgress, identity]); + + useEffect(() => { + const root = ref.current; + const control = root?.closest(iconControlSelector); + if (!root || !control || !allowed) return; + const cube = root.querySelector("[data-icon-cube]"); + const play = () => { + if (isIconControlDisabled(control)) return; + cancelInteraction.current = playIconAction( + root, + name, + Math.min(duration, 420), + false, + cancelInteraction.current, + ); + }; + const enter = () => { + if (!authoredHover || isIconControlDisabled(control)) return; + if (cube) { + cancelInteraction.current(); + const angle = Number(cube.getAttribute("data-cube-angle") ?? 35); + const rest = 35 + Math.round((angle - 35) / 90) * 90; + cancelInteraction.current = animateCube( + cube, + rest + 20, + Math.min(duration, 240), + ); + } else + cancelInteraction.current = playIconAction( + root, + name, + Math.min(duration, 360), + false, + cancelInteraction.current, + ); + }; + const leave = () => { + if (!cube || !authoredHover) return; + cancelInteraction.current(); + const angle = Number(cube.getAttribute("data-cube-angle") ?? 35); + cancelInteraction.current = animateCube( + cube, + 35 + Math.round((angle - 35) / 90) * 90, + Math.min(duration, 220), + ); + }; + if (interactive && !effectKey) control.addEventListener("click", play); + control.addEventListener("pointerenter", enter); + control.addEventListener("pointerleave", leave); + control.addEventListener("focus", enter); + control.addEventListener("blur", leave); + return () => { + cancelInteraction.current(); + if (cube) paintCube(cube, 35); + control.removeEventListener("click", play); + control.removeEventListener("pointerenter", enter); + control.removeEventListener("pointerleave", leave); + control.removeEventListener("focus", enter); + control.removeEventListener("blur", leave); + }; + }, [allowed, authoredHover, interactive, effectKey, name, duration]); + + useEffect(() => { + const triggered = previousTrigger.current !== trigger; + previousTrigger.current = trigger; + const root = ref.current; + if ( + !root || + !allowed || + active === false || + (active !== true && trigger !== undefined && !triggered) + ) { + return; + } + + const animations: Animation[] = []; + let cancelAction = () => {}; + const totalDuration = resolveIconDuration(duration); + const effects = experimentalIconEffects.filter((candidate) => + effectKey.split(",").includes(candidate), + ); + for (const currentEffect of effects) { + if (currentEffect === "action") { + cancelAction = playIconAction( + root, + name, + totalDuration, + active === true, + cancelInteraction.current, + ); + cancelInteraction.current = cancelAction; + continue; + } + if (currentEffect === "draw" && drawProgress !== undefined) continue; + const targets = root.querySelectorAll( + currentEffect === "draw" + ? '[data-icon-draw]:not([visibility="hidden"])' + : `[data-icon-effect="${currentEffect}"]`, + ); + const layerNames = Array.from( + new Set( + Array.from(targets, (target) => + currentEffect === "draw" + ? target.getAttribute("data-icon-draw") + : target + .closest("[data-icon-layer]") + ?.getAttribute("data-icon-layer"), + ), + ), + ); + if (currentEffect !== "draw") { + layerNames.sort((left, right) => + left === "frame" ? -1 : right === "frame" ? 1 : 0, + ); + } + const stagger = + choreography === "together" ? 0 : choreography === "stagger" ? 0.15 : 1; + const segmentDuration = + totalDuration / (1 + Math.max(0, layerNames.length - 1) * stagger); + targets.forEach((target) => { + const layerName = + currentEffect === "draw" + ? target.getAttribute("data-icon-draw") + : target + .closest("[data-icon-layer]") + ?.getAttribute("data-icon-layer"); + const index = layerNames.indexOf(layerName); + if (typeof target.animate !== "function") { + return; + } + const keyframes = + currentEffect === "draw" + ? [ + { strokeDasharray: "1 1", strokeDashoffset: 1, fillOpacity: 0 }, + { strokeDasharray: "1 1", strokeDashoffset: 0, fillOpacity: 1 }, + ] + : effectKeyframes[currentEffect]; + // Keep each layer on the same loop period, including its staggered hold. + const delay = index * stagger * segmentDuration; + const frames = keyframes.map((keyframe, keyframeIndex) => ({ + ...keyframe, + offset: Math.min( + 1, + Math.max( + 0, + (delay + + (keyframe.offset ?? keyframeIndex / (keyframes.length - 1)) * + segmentDuration) / + totalDuration, + ), + ), + })); + const animation = target.animate( + [ + { ...keyframes.at(0), offset: 0 }, + ...frames, + { ...keyframes.at(-1), offset: 1 }, + ], + { + duration: totalDuration, + iterations: active === true ? Infinity : 1, + easing: currentEffect === "rotate" ? "linear" : "ease-in-out", + fill: "none", + }, + ); + animations.push(animation); + }); + } + return () => { + cancelAction(); + animations.forEach((animation) => animation.cancel()); + }; + }, [ + effectKey, + trigger, + active, + duration, + choreography, + allowed, + identity, + name, + drawProgress, + ]); + + return ref; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion/action-motion.ts b/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion/action-motion.ts new file mode 100644 index 00000000000..1fdd6f9d0ed --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion/action-motion.ts @@ -0,0 +1,199 @@ +import { animateCube } from "../shared/cube-projection"; +import { petriconHints } from "../shared/petricon-hints"; + +import type { ExperimentalIconName } from "../../experimental-icons"; + +export const iconControlSelector = + 'button, a[href], [role="button"], [role="tab"], [role="menuitem"], [data-scope="segment-group"][data-part="item"], label[data-scope="checkbox"]'; +export const isIconControlDisabled = (control: Element) => + Boolean( + control.closest(':disabled, [aria-disabled="true"], [data-disabled]'), + ); + +const translations: Record = { + right: "translateX(2px)", + left: "translateX(-2px)", + up: "translateY(-2px)", + lift: "translateY(-2px)", + down: "translateY(2px)", + out: "translate(1.4px, -1.4px)", + grow: "scale(1.12)", + shrink: "scale(.88)", + tilt: "rotate(-9deg)", + turn: "rotate(25deg)", + rewind: "rotate(-25deg)", +}; + +const detailTransforms: Record = { + "rewind-ring": "rotate(-18deg)", + "rewind-hand": "rotate(-100deg)", + "derivative-top": "translateY(-1px)", + "derivative-bottom": "translateY(1px)", + "dial-needle": "rotate(40deg)", + "range-left": "translateX(2px)", + "range-right": "translateX(-2px)", + "range-value": "scaleX(1.4)", + "variable-left": "skewX(7deg)", + "variable-right": "skewX(-7deg)", + "variable-value": "translateY(-1px) scale(.9)", + "value-cursor": "scaleY(.5)", + "token-top": "translateY(1px)", + "token-left": "translate(-.8px, -.5px)", + "token-right": "translate(.8px, -.5px)", + "token-stack-top": "translateY(-1.5px)", + "token-stack-middle": "translateY(-.7px)", + "tag-token": "scale(.78)", + "subnet-node": "translateY(-.8px)", + "module-front": "translate(-1px, 1px)", + "slope-field": "skewY(-7deg)", + "open-left": "translateX(-1.2px)", + "open-right": "translateX(1.2px)", + notation: "translate(1px, -.5px)", + "type-mark": "translateY(-1px)", + bounds: "scaleY(1.12)", + row: "translateX(1px)", + cells: "scale(.88)", + node: "scale(.85)", + connection: "scaleY(.92)", + "test-result": "scale(1.15)", + distribution: "skewX(-6deg)", + "liquid-level": "rotate(-8deg)", + orbit: "rotate(30deg)", + antenna: "rotate(12deg)", + eyes: "scaleY(.15)", + "scan-bar": "scaleY(.6)", + lid: "translateY(-2px) rotate(-12deg)", + sheet: "translate(1.5px, 1.5px)", + down: "translateY(2px)", + right: "translateX(2px)", + out: "translate(1.2px, -1.2px)", + dot: "scale(1.35)", + shackle: "translateY(-1.5px)", + pupil: "translateX(.7px)", + "clock-hand": "rotate(-45deg)", + bars: "scaleY(1.15)", + "line-chart": "translateY(-1px)", + lines: "translateX(1px)", + target: "scale(.8)", + sparkle: "scale(1.15) rotate(8deg)", + wave: "scaleY(.6)", + "bell-clapper": "translateX(1.2px)", + "calendar-day": "scale(1.2)", + "calendar-hand": "rotate(-30deg)", + filament: "translateY(-1px)", +}; + +export const playIconAction = ( + root: SVGGElement, + name: ExperimentalIconName, + duration: number, + loop = false, + cancelPrevious: () => void = () => {}, +) => { + const details = root.querySelectorAll("[data-icon-detail]"); + const previous = new Map( + Array.from(details, (detail) => [ + detail, + { + transform: getComputedStyle(detail).transform, + path: getComputedStyle(detail).getPropertyValue("d"), + }, + ]), + ); + cancelPrevious(); + const cube = root.querySelector("[data-icon-cube]"); + if (cube) { + let cancel = () => {}; + let timer: ReturnType | undefined; + const turn = () => { + cancel(); + cancel = animateCube( + cube, + Number(cube.getAttribute("data-cube-angle") ?? 35) + 90, + duration, + ); + }; + turn(); + if (loop) timer = setInterval(turn, duration); + return () => { + cancel(); + if (timer) clearInterval(timer); + }; + } + const animations: Animation[] = []; + const animate = (target: SVGElement, frames: Keyframe[], delay = 0) => { + if (typeof target.animate !== "function") return; + animations.push( + target.animate(frames, { + duration, + delay, + iterations: loop ? Infinity : 1, + easing: "cubic-bezier(.2, .7, .25, 1)", + fill: "none", + }), + ); + }; + for (const detail of details) { + const part = detail.getAttribute("data-icon-detail") ?? ""; + const resting = getComputedStyle(detail).transform; + if (part === "equation-curve" || part === "equation-solution") { + const original = detail.getAttribute("d"); + const wave = + part === "equation-curve" + ? "M6 17C9 12 15 15 19 6" + : "M3 17C10 11 14 16 21 8"; + animate(detail, [ + { d: previous.get(detail)?.path || `path('${original}')` }, + { d: `path('${wave}')`, offset: 0.4 }, + { d: `path('${original}')` }, + ]); + } else if (part === "subnet-link") { + animate(detail, [ + { strokeDasharray: "1 1", strokeDashoffset: 1, opacity: 0.25 }, + { strokeDasharray: "1 1", strokeDashoffset: 0, opacity: 1 }, + ]); + } else if (part === "curve-sample") { + animate(detail, [ + { transform: "translate(0, 0)" }, + { transform: "translate(3px, -3.7px)", offset: 0.45 }, + { transform: "translate(0, 0)" }, + ]); + } else { + const transform = + part === "slider" + ? `translateX(${detail.getAttribute("data-slider") === "bottom" ? -3 : 3}px)` + : detailTransforms[part]; + if (!transform) continue; + const origin = + part === "dial-needle" + ? "12px 14px" + : getComputedStyle(detail).transformOrigin; + animate(detail, [ + { + transform: previous.get(detail)?.transform ?? resting, + transformOrigin: origin, + }, + { transform, transformOrigin: origin, offset: 0.38 }, + { transform: resting, transformOrigin: origin }, + ]); + } + } + if (animations.length === 0 && petriconHints[name] !== "state") { + const target = root.querySelector("[data-icon-feedback]"); + if (target) + animate(target, [ + { transform: "none" }, + { + transform: translations[petriconHints[name]] ?? "scale(.92)", + offset: 0.35, + }, + { transform: "none" }, + ]); + } + let cancelled = false; + return () => { + if (cancelled) return; + cancelled = true; + animations.forEach((animation) => animation.cancel()); + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/parameter-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/parameter-geometry.tsx new file mode 100644 index 00000000000..88a62af2fa5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/parameter-geometry.tsx @@ -0,0 +1,79 @@ +import { useId } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +const scrubStyle = css({ + "--parameter-scrub-direction": "1", + '&[data-slider="bottom"]': { "--parameter-scrub-direction": "-1" }, + animationName: "[none]", + animationTimingFunction: "[cubic-bezier(0.4, 0, 0.2, 1)]", + animationIterationCount: "[1]", +}); + +const ParameterKnobs = ({ + duration, + animated, + masked = false, +}: { + duration: number; + animated: boolean; + masked?: boolean; +}) => ( + <> + {(["top", "bottom"] as const).map((slider) => ( + + + + ))} + +); + +export const ParameterGeometry = ({ + duration, + animated, +}: { + duration: number; + animated: boolean; +}) => { + const trackMaskId = useId(); + + return ( + + + + + + + + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/settings-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/settings-geometry.tsx new file mode 100644 index 00000000000..6dc0efff290 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/settings-geometry.tsx @@ -0,0 +1,27 @@ +import { IconMotionLayer } from "./shared/icon-motion-layer"; + +export const SettingsGeometry = ({ + open, + transition, +}: { + open: boolean; + transition: string; +}) => ( + + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/shapes-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shapes-geometry.tsx new file mode 100644 index 00000000000..3badce3f293 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shapes-geometry.tsx @@ -0,0 +1,87 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import { IconMotionLayer } from "./shared/icon-motion-layer"; + +const shapesStyle = css({ + "--shapes-selected": "0", + "--shapes-hover": "0", + "--triangle-rotation": "0deg", + "--triangle-hint": "75deg", + '&[data-selected="true"], [data-scope="segment-group"][data-part="item"][data-state="checked"] &, button[aria-pressed="true"] &': + { + "--shapes-selected": "1", + "--triangle-rotation": "90deg", + "--triangle-hint": "12deg", + }, + '&[data-hover="true"]:is(button:is(:hover, :focus-visible) *, a[href]:is(:hover, :focus-visible) *, [role="tab"]:is(:hover, :focus-visible) *, [role="menuitem"][data-highlighted] *, [data-icon-preview]:hover *, [data-scope="segment-group"][data-part="item"]:is(:hover, [data-focus-visible]) *):not(:is(button:disabled *, [aria-disabled="true"] *, [data-disabled] *))': + { "--shapes-hover": "1" }, +}); + +export const ShapesGeometry = ({ + selected, + hover, + strokeWidth, + transition, +}: { + selected: boolean; + hover: boolean; + strokeWidth: number | string; + transition: string; +}) => ( + + + + + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.test.ts b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.test.ts new file mode 100644 index 00000000000..8e5137bf22c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { projectCube } from "./cube-projection"; + +describe("subnet cube projection", () => { + it("removes the three edges meeting at the rear corner", () => { + expect( + projectCube(35) + .filter((edge) => !edge.visible) + .map((edge) => edge.index), + ).toEqual([1, 2, 10]); + expect( + projectCube(125) + .filter((edge) => !edge.visible) + .map((edge) => edge.index), + ).toEqual([5, 6, 10]); + }); + + it("keeps a full turn within the icon grid and never reveals rear edges", () => { + for (let angle = 0; angle <= 360; angle++) { + const edges = projectCube(angle); + expect(edges.filter((edge) => edge.visible)).toHaveLength( + angle % 90 === 0 ? 7 : 9, + ); + for (const edge of edges) { + const coordinates = + edge.path.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []; + expect(coordinates).toHaveLength(4); + expect( + coordinates.every( + (coordinate) => coordinate >= 2 && coordinate <= 22, + ), + ).toBe(true); + } + } + }); + + it("returns to the same projected shape after a full turn", () => { + expect(projectCube(395)).toEqual(projectCube(35)); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.ts b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.ts new file mode 100644 index 00000000000..3ca0bc73d65 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.ts @@ -0,0 +1,88 @@ +type Point3 = readonly [number, number, number]; + +const vertices: readonly Point3[] = [ + [-1, -1, -1], + [1, -1, -1], + [1, 1, -1], + [-1, 1, -1], + [-1, -1, 1], + [1, -1, 1], + [1, 1, 1], + [-1, 1, 1], +]; +const faces = [ + { normal: [0, 0, -1], vertices: [0, 1, 2, 3] }, + { normal: [0, 0, 1], vertices: [4, 5, 6, 7] }, + { normal: [-1, 0, 0], vertices: [0, 3, 7, 4] }, + { normal: [1, 0, 0], vertices: [1, 2, 6, 5] }, + { normal: [0, -1, 0], vertices: [0, 1, 5, 4] }, + { normal: [0, 1, 0], vertices: [3, 2, 6, 7] }, +] satisfies { normal: Point3; vertices: number[] }[]; +const edges = [ + [0, 1], + [1, 2], + [2, 3], + [3, 0], + [4, 5], + [5, 6], + [6, 7], + [7, 4], + [0, 4], + [1, 5], + [2, 6], + [3, 7], +] as const; + +export const projectCube = (degrees: number) => { + const yaw = (degrees * Math.PI) / 180; + const pitch = -Math.PI / 7; + const rotate = ([horizontal, vertical, depth]: Point3): Point3 => { + const turnedDepth = -horizontal * Math.sin(yaw) + depth * Math.cos(yaw); + return [ + horizontal * Math.cos(yaw) + depth * Math.sin(yaw), + vertical * Math.cos(pitch) - turnedDepth * Math.sin(pitch), + vertical * Math.sin(pitch) + turnedDepth * Math.cos(pitch), + ]; + }; + const visibleFaces = faces.filter((face) => rotate(face.normal)[2] > 0.00001); + const projected = vertices.map((vertex) => { + const [horizontal, vertical] = rotate(vertex); + return `${(12 + horizontal * 5.8).toFixed(3)} ${(12 + vertical * 5.8).toFixed(3)}`; + }); + return edges.map(([start, end], index) => ({ + index, + path: `M${projected[start]}L${projected[end]}`, + visible: visibleFaces.some( + (face) => face.vertices.includes(start) && face.vertices.includes(end), + ), + })); +}; + +export const paintCube = (root: Element, angle: number) => { + root.setAttribute("data-cube-angle", String(angle)); + const paths = root.querySelectorAll("[data-cube-edge]"); + for (const edge of projectCube(angle)) { + const path = paths[edge.index]; + path?.setAttribute("d", edge.path); + path?.setAttribute("visibility", edge.visible ? "visible" : "hidden"); + } +}; + +export const animateCube = ( + root: Element, + target: number, + duration: number, +) => { + const start = Number(root.getAttribute("data-cube-angle") ?? 35); + let frame = 0; + let started: number | undefined; + const tick = (now: number) => { + started ??= now; + const progress = Math.min(1, (now - started) / duration); + const eased = 1 - (1 - progress) ** 3; + paintCube(root, start + (target - start) * eased); + if (progress < 1) frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/icon-motion-layer.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/icon-motion-layer.tsx new file mode 100644 index 00000000000..26352bff43d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/icon-motion-layer.tsx @@ -0,0 +1,17 @@ +import type { PropsWithChildren } from "react"; + +export const IconMotionLayer = ({ + name, + origin = "12px 12px", + children, +}: PropsWithChildren<{ name: string; origin?: string }>) => ( + + + + + {children} + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/petricon-hints.ts b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/petricon-hints.ts new file mode 100644 index 00000000000..cb05f17d8e5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/petricon-hints.ts @@ -0,0 +1,188 @@ +import type { ExperimentalIconName } from "../../experimental-icons"; + +export type PetriconHint = + | "detail" + | "down" + | "grow" + | "layers" + | "left" + | "lift" + | "liquid" + | "out" + | "rewind" + | "right" + | "sequence" + | "shapes" + | "shrink" + | "state" + | "tilt" + | "turn" + | "up"; + +export const petriconHints = { + infinity: "grow", + arrowUp: "up", + arrowDown: "down", + arrowLeft: "left", + arrowRight: "right", + arrowUpRight: "out", + arrowsLeftRight: "grow", + trash: "detail", + pencil: "tilt", + copy: "detail", + download: "detail", + externalLink: "detail", + ellipsis: "detail", + ellipsisVertical: "detail", + circleEllipsis: "sequence", + collapse: "shrink", + lockOpen: "detail", + lockClosed: "detail", + eye: "detail", + eyeSlash: "detail", + clock: "detail", + clockRotateLeft: "detail", + rotate: "turn", + rightToLine: "detail", + flask: "liquid", + layer: "layers", + chartBarSimple: "detail", + chartLine: "detail", + filter: "shrink", + bracketsCurly: "grow", + lambda: "lift", + lightning: "lift", + code: "detail", + cube: "detail", + diagramNested: "sequence", + diagramProject: "sequence", + shapes: "shapes", + file: "lift", + fileLines: "detail", + list: "detail", + table: "sequence", + grid: "sequence", + bullseye: "detail", + scribble: "right", + sparkles: "detail", + info: "sequence", + error: "sequence", + warning: "sequence", + circleCheck: "sequence", + terminal: "detail", + text: "sequence", + user: "detail", + voice: "detail", + transcription: "detail", + assistant: "detail", + arrowTrendUp: "out", + arrowTrendDown: "down", + asterisk: "turn", + at: "turn", + barcode: "detail", + bell: "tilt", + bracketsSquare: "detail", + bug: "detail", + calendar: "detail", + calendarClock: "detail", + circleOne: "sequence", + cubes: "sequence", + diagramNodes: "sequence", + diagramSubtask: "sequence", + diamondExclamation: "sequence", + emptySet: "turn", + feather: "tilt", + fileSpreadsheet: "sequence", + gripVertical: "sequence", + image: "detail", + inputPipe: "right", + lightbulbOn: "detail", + listTree: "sequence", + magic: "detail", + memoCircleCheck: "sequence", + microscope: "tilt", + oneHundred: "sequence", + personRunning: "right", + plug: "detail", + print: "detail", + puzzlePiece: "shrink", + ruler: "tilt", + sortDown: "down", + sortUp: "up", + sortDownAZ: "down", + sortUpAZ: "up", + squareCheck: "sequence", + star: "tilt", + thoughtBubble: "detail", + truck: "right", + userPlus: "detail", + sum: "detail", + sumRange: "detail", + integral: "detail", + integralBounds: "detail", + matrix: "detail", + matrixGrid: "detail", + probability: "detail", + probabilityDice: "detail", + expression: "detail", + expressionTree: "detail", + typeVariable: "detail", + typeUnion: "detail", + breakpoint: "detail", + breakpointLine: "detail", + testTube: "detail", + testCheck: "detail", + agent: "detail", + agentOrbit: "detail", + context: "detail", + contextWindow: "detail", + reasoning: "detail", + reasoningFork: "detail", + embedding: "detail", + embeddingVector: "detail", + sidebar: "state", + menu: "shrink", + microphone: "lift", + playback: "grow", + diagnostics: "state", + loading: "turn", + hand: "lift", + cursor: "tilt", + place: "grow", + transition: "grow", + addPlace: "state", + addTransition: "state", + settings: "state", + tokenType: "detail", + parameter: "detail", + function: "lift", + play: "right", + pause: "shrink", + stepForward: "right", + stop: "shrink", + reset: "detail", + plus: "grow", + minus: "shrink", + close: "shrink", + check: "lift", + search: "grow", + zoomIn: "grow", + zoomOut: "shrink", + fitView: "grow", + chevronUp: "up", + chevronDown: "down", + chevronLeft: "left", + chevronRight: "right", + differentialEquation: "detail", + equationCurve: "detail", + equationFlow: "detail", + parameterDial: "detail", + parameterRange: "detail", + variable: "detail", + variableBrackets: "detail", + variableRegister: "detail", + tokenTypeStack: "detail", + tokenTypeTag: "detail", + subnetNetwork: "detail", + subnetLayers: "detail", +} as const satisfies Record; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/sidebar-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/sidebar-geometry.tsx new file mode 100644 index 00000000000..8d9f4c5f3c2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/sidebar-geometry.tsx @@ -0,0 +1,74 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import { IconMotionLayer } from "./shared/icon-motion-layer"; + +const sidebarStyle = css({ + "--sidebar-divider-offset": "1px", + "--sidebar-divider-scale": "1", + "--sidebar-divider-width-scale": "1", + "--sidebar-panel-scale": "1.166667", + '&[data-collapsed="true"]': { + "--sidebar-divider-offset": "-2.5px", + "--sidebar-divider-scale": "0.6875", + "--sidebar-divider-width-scale": "0.75", + "--sidebar-panel-scale": "0", + }, + '&[data-hover-motion="auto"][data-collapsed="false"]:is(svg:hover *, button:hover *)': + { + "--sidebar-divider-offset": "0.25px", + "--sidebar-panel-scale": "1.041667", + }, + '&[data-hover-motion="auto"][data-collapsed="true"]:is(svg:hover *, button:hover *)': + { + "--sidebar-divider-offset": "-1px", + "--sidebar-divider-scale": "0.625", + "--sidebar-divider-width-scale": "1", + }, +}); + +export const SidebarGeometry = ({ + collapsed, + filled, + motionAllowed, + transition, +}: { + collapsed: boolean; + filled: boolean; + motionAllowed: boolean; + transition: string; +}) => ( + + + + + + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/simulation-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/simulation-geometry.tsx new file mode 100644 index 00000000000..3986e96e275 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/simulation-geometry.tsx @@ -0,0 +1,204 @@ +import { useId } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { IconMotionLayer } from "./shared/icon-motion-layer"; + +import type { SVGProps } from "react"; + +const simulationStyle = css({ + "--simulation-selected": "0", + "--simulation-hover": "0", + "--simulation-pressed": "0", + '&[data-selected="true"], [data-scope="segment-group"][data-part="item"][data-state="checked"] &, button[aria-pressed="true"] &': + { "--simulation-selected": "1" }, + '&[data-hover="true"]:is(button:is(:hover, :focus-visible) *, a[href]:is(:hover, :focus-visible) *, [data-scope="segment-group"][data-part="item"]:is(:hover, [data-focus-visible]) *, [data-icon-preview]:hover *):not(:is(button:disabled *, [aria-disabled="true"] *, [data-disabled] *))': + { + "--simulation-hover": "1", + '&[data-wave="true"] [data-icon-part="bubbles"]': { + animationName: "[petrinautIconLiquidWave]", + }, + }, + '&[data-hover="true"]:is(button:active *, [data-scope="segment-group"][data-part="item"]:active *):not(:is(button:disabled *, [aria-disabled="true"] *, [data-disabled] *))': + { "--simulation-pressed": "1" }, +}); + +const waveStyle = css({ + animationName: "[none]", + animationTimingFunction: "[cubic-bezier(0.4, 0, 0.2, 1)]", + animationIterationCount: "[1]", +}); + +type SimulationGeometryProps = { + selected: boolean; + hover: boolean; + filled: boolean; + transition: string; +}; + +const flaskOutline = + "M9 3v7L4 19a1.3 1.3 0 0 0 1 2h14a1.3 1.3 0 0 0 1-2l-5-9V3Z"; +const flatLiquid = + "M-6 15.5C-2 15.5 2 15.5 6 15.5S14 15.5 18 15.5S26 15.5 30 15.5V26H-6Z"; +const liquidWave = [ + flatLiquid, + "M-6 15.5C-2 15.5 2 11.5 6 13.5S14 18.5 18 16.5S26 14.5 30 15.5V26H-6Z", + "M-6 15.5C-2 15.5 2 16.5 6 17S14 11.5 18 13.5S26 18 30 15.5V26H-6Z", + "M-6 15.5C-2 15.5 2 14.5 6 15S14 16.5 18 16S26 15 30 15.5V26H-6Z", + flatLiquid, +].join(";"); + +const LiquidPath = ({ + animated, + duration, + ...props +}: SVGProps & { animated: boolean; duration: number }) => ( + + {animated && ( + + )} + +); + +export const FlaskGeometry = ({ + selected, + hover, + filled, + transition, + waveDuration, +}: SimulationGeometryProps & { waveDuration: number }) => { + const clipId = useId(); + const waveEnabled = hover && transition !== "none"; + + return ( + + { + if (event.animationName === "petrinautIconLiquidWave") { + event.currentTarget + .querySelectorAll("animate") + .forEach((animation) => animation.beginElement()); + } + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const LayerGeometry = ({ + selected, + hover, + filled, + transition, +}: SimulationGeometryProps) => ( + + + + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/state-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/state-geometry.tsx new file mode 100644 index 00000000000..a8793f7fa11 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/state-geometry.tsx @@ -0,0 +1,160 @@ +import { css } from "@hashintel/ds-helpers/css"; + +const menuStyle = css({ + "--menu-open": "0", + '&[data-open="true"], button[aria-expanded="true"] &': { "--menu-open": "1" }, +}); + +export const MenuGeometry = ({ + open, + transition, +}: { + open: boolean; + transition: string; +}) => ( + + + + + +); + +export const PauseGeometry = () => ( + <> + + + +); + +export const PlaybackGeometry = ({ + playing, + transition, +}: { + playing: boolean; + transition: string; +}) => ( + + + + + + +); + +export type ExperimentalIconStatus = "valid" | "warning" | "error"; + +const diagnosticStrokes = { + valid: [ + { id: "leading", x: 5, y: 12, angle: 45, length: Math.hypot(4.5, 4.5) }, + { + id: "trailing", + x: 9.5, + y: 16.5, + angle: -45, + length: Math.hypot(9.5, 9.5), + }, + ], + warning: [ + { id: "leading", x: 12, y: 5, angle: 90, length: 9 }, + { id: "trailing", x: 12, y: 19, angle: -90, length: 0.01 }, + ], + error: [ + { id: "leading", x: 6, y: 6, angle: 45, length: Math.hypot(12, 12) }, + { id: "trailing", x: 6, y: 18, angle: -45, length: Math.hypot(12, 12) }, + ], +} satisfies Record< + ExperimentalIconStatus, + { + id: "leading" | "trailing"; + x: number; + y: number; + angle: number; + length: number; + }[] +>; + +export const DiagnosticsGeometry = ({ + status, + transition, +}: { + status: ExperimentalIconStatus; + transition: string; +}) => ( + + {diagnosticStrokes[status].map((stroke) => ( + + ))} + +); + +export const MicrophoneGeometry = ({ + muted, + transition, +}: { + muted: boolean; + transition: string; +}) => ( + + + + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/utility-geometry.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/utility-geometry.tsx new file mode 100644 index 00000000000..40d64fd5b45 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/utility-geometry.tsx @@ -0,0 +1,204 @@ +const calendarFrame = ; + +export const utilityGeometry = { + arrowTrendUp: , + arrowTrendDown: , + asterisk: , + at: ( + <> + + + + ), + barcode: ( + <> + {[3, 7, 10, 15, 18, 21].map((position) => ( + + ))} + + ), + bell: ( + <> + + + + ), + bracketsSquare: ( + <> + + + + ), + bug: ( + <> + + + + ), + calendar: ( + <> + {calendarFrame} + + + ), + calendarClock: ( + <> + + + + + ), + circleOne: ( + <> + + + + ), + cubes: ( + + ), + diagramNodes: ( + <> + + + + + + + + + ), + diagramSubtask: , + diamondExclamation: ( + <> + + + + ), + emptySet: ( + <> + + + + ), + feather: , + fileSpreadsheet: ( + + ), + gripVertical: ( + + + + + + + + + ), + image: ( + <> + + + + + ), + inputPipe: , + lightbulbOn: ( + <> + + + + ), + listTree: , + magic: ( + <> + + + + ), + memoCircleCheck: ( + <> + + + + + ), + microscope: ( + + ), + oneHundred: ( + <> + + + + + ), + personRunning: ( + <> + + + + ), + plug: ( + <> + + + + ), + print: ( + <> + + + + ), + puzzlePiece: ( + + ), + ruler: , + sortDown: , + sortUp: , + sortDownAZ: ( + + ), + sortUpAZ: , + squareCheck: ( + <> + + + + ), + star: ( + + ), + thoughtBubble: ( + <> + + + + + ), + truck: ( + <> + + + + + ), + userPlus: ( + <> + + + + ), +} as const; diff --git a/libs/@hashintel/petrinaut/src/ui/index.ts b/libs/@hashintel/petrinaut/src/ui/index.ts index e6975730ee9..ff4851052c6 100644 --- a/libs/@hashintel/petrinaut/src/ui/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/index.ts @@ -10,6 +10,179 @@ */ export { Petrinaut } from "./petrinaut"; +export { + Petricon, + PetriconProvider, + petriconNames, + petriconPack, + petriconEffects, + getPetriconEffects, + petriconCatalog, + petriconStudies, + usePetriconMotionAllowed, +} from "./petricon"; +export type { + PetriconName, + PetriconProps, + PetriconDefaults, + PetriconStatus, + PetriconEffect, + PetriconMotion, + PetriconChoreography, + PetriconBadgeVisibility, + PetriconTransition, + PetriconVariant, +} from "./petricon"; +export { + ExperimentalIcon, + ExperimentalIconProvider, + DiagnosticsIcon, + LoadingIcon, + ArrowTrendDownIcon, + ArrowTrendUpIcon, + AsteriskIcon, + AtIcon, + BarcodeIcon, + BellIcon, + BracketsSquareIcon, + BugIcon, + CalendarIcon, + CalendarClockIcon, + CircleOneIcon, + CubesIcon, + DiagramNodesIcon, + DiagramSubtaskIcon, + DiamondExclamationIcon, + EmptySetIcon, + FeatherIcon, + FileSpreadsheetIcon, + GripVerticalIcon, + ImageIcon, + InputPipeIcon, + LightbulbOnIcon, + ListTreeIcon, + MagicIcon, + MemoCircleCheckIcon, + MicroscopeIcon, + OneHundredIcon, + PersonRunningIcon, + PlugIcon, + PrintIcon, + PuzzlePieceIcon, + RulerIcon, + SortDownIcon, + SortDownAZIcon, + SortUpIcon, + SortUpAZIcon, + SquareCheckIcon, + StarIcon, + ThoughtBubbleIcon, + TruckIcon, + UserPlusIcon, + experimentalIconNames, + experimentalIconEffects, + getExperimentalIconEffects, + experimentalIconPack, + InfinityIcon, + ArrowUpIcon, + ArrowDownIcon, + ArrowLeftIcon, + ArrowRightIcon, + ArrowUpRightIcon, + ArrowsLeftRightIcon, + TrashIcon, + PencilIcon, + CopyIcon, + DownloadIcon, + ExternalLinkIcon, + EllipsisIcon, + EllipsisVerticalIcon, + CircleEllipsisIcon, + CollapseIcon, + LockOpenIcon, + LockClosedIcon, + EyeIcon, + EyeSlashIcon, + ClockIcon, + ClockRotateLeftIcon, + RotateIcon, + RightToLineIcon, + FlaskIcon, + LayerIcon, + ChartBarSimpleIcon, + ChartLineIcon, + FilterIcon, + BracketsCurlyIcon, + LambdaIcon, + LightningIcon, + CodeIcon, + CubeIcon, + DiagramNestedIcon, + DiagramProjectIcon, + ShapesIcon, + FileIcon, + FileLinesIcon, + ListIcon, + TableIcon, + GridIcon, + BullseyeIcon, + ScribbleIcon, + SparklesIcon, + InfoIcon, + ErrorIcon, + WarningIcon, + CircleCheckIcon, + TerminalIcon, + TextIcon, + UserIcon, + VoiceIcon, + TranscriptionIcon, + AssistantIcon, + MicrophoneIcon, + PlaybackIcon, + SidebarIcon, + MenuIcon, + HandIcon, + CursorIcon, + AddPlaceIcon, + AddTransitionIcon, + SettingsIcon, + PlaceIcon, + TransitionIcon, + TokenTypeIcon, + ParameterIcon, + FunctionIcon, + PlayIcon, + PauseIcon, + StepForwardIcon, + StopIcon, + ResetIcon, + PlusIcon, + MinusIcon, + CloseIcon, + CheckIcon, + SearchIcon, + ZoomInIcon, + ZoomOutIcon, + FitViewIcon, + ChevronUpIcon, + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "./experimental-icons"; +export type { + ExperimentalIconName, + ExperimentalIconStatus, + ExperimentalIconProps, + ExperimentalIconComponentProps, + ExperimentalIconDefaults, + ExperimentalIconVariant, + ExperimentalIconBadgeVisibility, + ExperimentalIconEffect, + ExperimentalIconMotion, + ExperimentalIconTransition, + ExperimentalIconChoreography, +} from "./experimental-icons"; export { // The user-guide pages the built-in documentation read serves, so a host // tool under its own name can answer with the same text. diff --git a/libs/@hashintel/petrinaut/src/ui/petricon.ts b/libs/@hashintel/petrinaut/src/ui/petricon.ts new file mode 100644 index 00000000000..a1aeb6c537e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/petricon.ts @@ -0,0 +1,278 @@ +import { experimentalIconNames, petriconHints } from "./experimental-icons"; + +import type { ExperimentalIconName } from "./experimental-icons"; + +export { + ExperimentalIcon as Petricon, + ExperimentalIconProvider as PetriconProvider, + experimentalIconNames as petriconNames, + experimentalIconPack as petriconPack, + experimentalIconEffects as petriconEffects, + getExperimentalIconEffects as getPetriconEffects, + useExperimentalIconMotionAllowed as usePetriconMotionAllowed, +} from "./experimental-icons"; +export type { + ExperimentalIconName as PetriconName, + ExperimentalIconProps as PetriconProps, + ExperimentalIconDefaults as PetriconDefaults, + ExperimentalIconStatus as PetriconStatus, + ExperimentalIconEffect as PetriconEffect, + ExperimentalIconMotion as PetriconMotion, + ExperimentalIconChoreography as PetriconChoreography, + ExperimentalIconBadgeVisibility as PetriconBadgeVisibility, + ExperimentalIconTransition as PetriconTransition, + ExperimentalIconVariant as PetriconVariant, +} from "./experimental-icons"; + +export const petriconStudies = [ + { + concept: "Differential equations", + category: "Modeling", + names: ["differentialEquation", "equationCurve", "equationFlow"], + variants: ["Rate of change", "Solution curve", "Slope field"], + motion: + "The derivative opens, a solution evolves, and the field bends around its trajectory.", + }, + { + concept: "Parameters", + category: "Modeling", + names: ["parameter", "parameterDial", "parameterRange"], + variants: ["Sliders", "Calibration dial", "Value bounds"], + motion: + "Knobs scrub, the dial adjusts, and bounds narrow, then return to their initial values.", + }, + { + concept: "Variables", + category: "Modeling", + names: ["variable", "variableBrackets", "variableRegister"], + variants: ["A changing value", "Bound expression", "Value register"], + motion: + "The value flexes or shifts inside a stable container; the register cursor responds.", + }, + { + concept: "Token types", + category: "Modeling", + names: ["tokenType", "tokenTypeStack", "tokenTypeTag"], + variants: ["Token family", "Typed collection", "Type label"], + motion: + "Tokens gather, a collection separates, or a token responds inside its label.", + }, + { + concept: "Subnets", + category: "Modeling", + names: ["cube", "subnetNetwork", "subnetLayers"], + variants: ["Spatial module", "Contained net", "Nested modules"], + motion: + "The cube turns in depth with hidden edges removed. Connections trace and nested modules separate.", + }, + { + concept: "Summation", + category: "Math", + names: ["sum", "sumRange"], + variants: ["A single operator", "An operator with bounds"], + motion: "The notation makes a small writing gesture.", + }, + { + concept: "Integration", + category: "Math", + names: ["integral", "integralBounds"], + variants: ["A continuous integral", "A bounded interval"], + motion: "The curve shifts as its bounds open.", + }, + { + concept: "Matrix", + category: "Math", + names: ["matrix", "matrixGrid"], + variants: ["Individual values", "Rows of values"], + motion: "Values respond inside a steady frame.", + }, + { + concept: "Probability", + category: "Math", + names: ["probability", "probabilityDice"], + variants: ["A distribution", "A discrete outcome"], + motion: "The distribution sways; the dice values pulse.", + }, + { + concept: "Expression", + category: "Code", + names: ["expression", "expressionTree"], + variants: ["Inline syntax", "A syntax tree"], + motion: "Brackets open; the root node responds.", + }, + { + concept: "Type", + category: "Code", + names: ["typeVariable", "typeUnion"], + variants: ["A type variable", "A choice of types"], + motion: "A small writing gesture or two separating choices.", + }, + { + concept: "Breakpoint", + category: "Code", + names: ["breakpoint", "breakpointLine"], + variants: ["An execution stop", "A stop on a code line"], + motion: "The stop marker draws inward.", + }, + { + concept: "Test", + category: "Code", + names: ["testTube", "testCheck"], + variants: ["An experiment", "A validation result"], + motion: "The liquid shifts and the result gives a short pulse.", + }, + { + concept: "Agent", + category: "AI", + names: ["agent", "agentOrbit"], + variants: ["A helpful robot", "An autonomous process"], + motion: "The robot blinks; the orbit turns and settles.", + }, + { + concept: "Context", + category: "AI", + names: ["context", "contextWindow"], + variants: ["Supporting documents", "A context window"], + motion: "Pages separate; the context window opens.", + }, + { + concept: "Reasoning", + category: "AI", + names: ["reasoning", "reasoningFork"], + variants: ["Sequential steps", "Alternative paths"], + motion: "A signal passes between connected steps.", + }, + { + concept: "Embedding", + category: "AI", + names: ["embedding", "embeddingVector"], + variants: ["A space of values", "A vector in that space"], + motion: "Values pulse; the vector moves in its direction.", + }, +] as const satisfies readonly { + concept: string; + category: string; + names: readonly ExperimentalIconName[]; + variants: readonly string[]; + motion: string; +}[]; + +const categories = { + Navigation: [ + "arrowUp", + "arrowDown", + "arrowLeft", + "arrowRight", + "arrowUpRight", + "arrowsLeftRight", + "arrowTrendUp", + "arrowTrendDown", + "chevronUp", + "chevronDown", + "chevronLeft", + "chevronRight", + "externalLink", + "rightToLine", + "sidebar", + "menu", + "collapse", + "inputPipe", + ], + Editing: [ + "hand", + "cursor", + "shapes", + "pencil", + "copy", + "trash", + "plus", + "minus", + "close", + "check", + "search", + "zoomIn", + "zoomOut", + "fitView", + "parameter", + "filter", + "sortUp", + "sortDown", + "sortUpAZ", + "sortDownAZ", + "ruler", + "gripVertical", + ], + Simulation: [ + "flask", + "layer", + "place", + "transition", + "addPlace", + "addTransition", + "tokenType", + "play", + "pause", + "playback", + "stepForward", + "stop", + "reset", + "diagnostics", + "chartLine", + "chartBarSimple", + "clock", + "clockRotateLeft", + "microscope", + "bullseye", + ], + Math: [ + "infinity", + "lambda", + "function", + "emptySet", + "asterisk", + "oneHundred", + "circleOne", + ], + Code: [ + "code", + "bracketsCurly", + "bracketsSquare", + "terminal", + "bug", + "diagramNested", + "diagramProject", + "diagramNodes", + "diagramSubtask", + ], + AI: [ + "assistant", + "agent", + "sparkles", + "magic", + "lightbulbOn", + "thoughtBubble", + "microphone", + "voice", + "transcription", + ], +} satisfies Record; + +export const petriconCatalog = experimentalIconNames.map((name) => { + const study = petriconStudies.find((candidate) => + candidate.names.some((variant) => variant === name), + ); + const category = + study?.category ?? + Object.entries(categories).find(([, names]) => + (names as readonly string[]).includes(name), + )?.[0] ?? + "Interface"; + const words = name.replace(/([A-Z])/g, " $1"); + return { + name, + label: words.charAt(0).toUpperCase() + words.slice(1), + category, + motion: petriconHints[name], + study: study?.concept, + }; +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/diagnostics-indicator.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/diagnostics-indicator.test.tsx new file mode 100644 index 00000000000..7fda0d2c2ff --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/diagnostics-indicator.test.tsx @@ -0,0 +1,89 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; + +import { + DEFAULT_LANGUAGE_CLIENT_CONTEXT, + LanguageClientContext, +} from "../../../../../react/lsp/context"; +import { ExperimentalIconProvider } from "../../../../experimental-icons"; +import { DiagnosticsIndicator } from "./diagnostics-indicator"; + +afterEach(cleanup); + +it("keeps one animated icon as live diagnostics change severity", () => { + const onClick = vi.fn(); + const indicator = (total: number, errors: number, enabled = true) => ( + + + + + + ); + const { rerender } = render(indicator(0, 0)); + const icon = screen + .getByRole("button", { name: "No diagnostic issues" }) + .querySelector("svg"); + expect(icon?.getAttribute("data-icon-status")).toBe("valid"); + const strokes = Array.from(icon?.querySelectorAll("path") ?? []); + expect(strokes).toHaveLength(2); + + rerender(indicator(2, 1)); + const errorButton = screen.getByRole("button", { + name: "2 diagnostic issues found", + }); + expect(errorButton.querySelector("svg")).toBe(icon); + expect(icon?.getAttribute("data-icon-status")).toBe("error"); + expect(Array.from(icon?.querySelectorAll("path") ?? [])).toEqual(strokes); + const countWindow = errorButton.querySelector( + "[data-diagnostic-count]", + ); + const count = countWindow?.firstElementChild; + expect(screen.getByText("2")).toBeTruthy(); + fireEvent.click(errorButton); + expect(onClick).toHaveBeenCalledOnce(); + + rerender(indicator(1, 0)); + expect(icon?.getAttribute("data-icon-status")).toBe("warning"); + expect(Array.from(icon?.querySelectorAll("path") ?? [])).toEqual(strokes); + expect(countWindow?.firstElementChild).toBe(count); + expect(screen.queryByText("2")).toBeNull(); + + rerender(indicator(0, 0)); + expect( + screen + .getByRole("button", { name: "No diagnostic issues" }) + .querySelector("svg"), + ).toBe(icon); + expect(icon?.getAttribute("data-icon-status")).toBe("valid"); + expect(Array.from(icon?.querySelectorAll("path") ?? [])).toEqual(strokes); + for (const stroke of strokes) { + expect(stroke.style.opacity).toBe(""); + expect(stroke.style.transition).toBe("none"); + } + expect(count?.textContent).toBe("1"); + expect(countWindow?.getAttribute("aria-hidden")).toBe("true"); + expect(countWindow?.style.width).toBe("0px"); + expect(countWindow?.style.transition).toBe("none"); + + rerender(indicator(12, 1)); + expect(countWindow?.firstElementChild).toBe(count); + expect(count?.textContent).toBe("12"); + expect( + screen.getByRole("button", { name: "12 diagnostic issues found" }), + ).toBe(errorButton); + + rerender(indicator(1, 1, false)); + expect( + screen + .getByRole("button", { name: "1 diagnostic issues found" }) + .querySelector("svg") + ?.hasAttribute("data-icon-pack"), + ).toBe(false); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/diagnostics-indicator.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/diagnostics-indicator.tsx index 5773fc2cb6d..be777c0dc23 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/diagnostics-indicator.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/diagnostics-indicator.tsx @@ -1,9 +1,14 @@ -import { use } from "react"; +import { use, useState } from "react"; import { Icon } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; import { LanguageClientContext } from "../../../../../react/lsp/context"; +import { + DiagnosticsIcon, + useExperimentalIconMotionAllowed, + useExperimentalIconPackEnabled, +} from "../../../../experimental-icons"; import { ToolbarButton } from "./toolbar-button"; const iconContainerStyle = cva({ @@ -38,6 +43,75 @@ const countStyle = css({ fontWeight: "medium", }); +const animatedContainerStyle = css({ + display: "flex", + alignItems: "center", + fontSize: "sm", + lineHeight: "[20px]", + fontVariantNumeric: "tabular-nums", + "& > svg": { flexShrink: "0", position: "relative", zIndex: "[1]" }, +}); + +const countWindowStyle = css({ + flexShrink: "0", + overflow: "hidden", + maskImage: "[linear-gradient(to right, transparent, black 4px)]", +}); + +const animatedCountStyle = css({ + display: "block", + paddingLeft: "[4px]", + fontWeight: "medium", + whiteSpace: "nowrap", +}); + +const AnimatedDiagnosticsContents = ({ + count, + status, + motionAllowed, +}: { + count: number; + status: "valid" | "warning" | "error"; + motionAllowed: boolean; +}) => { + const [displayedCount, setDisplayedCount] = useState(count); + if (count > 0 && count !== displayedCount) { + setDisplayedCount(count); + } + const hasIssues = count > 0; + const timing = "320ms cubic-bezier(0.2, 0, 0.2, 1)"; + + return ( +
+ + +
+ ); +}; + interface DiagnosticsIndicatorProps { onClick: () => void; isExpanded: boolean; @@ -59,6 +133,9 @@ export const DiagnosticsIndicator: React.FC = ({ const hasErrors = errorDiagnosticsCount > 0; const hasIssues = totalDiagnosticsCount > 0; + const experimentalIcons = useExperimentalIconPackEnabled(); + const motionAllowed = useExperimentalIconMotionAllowed(); + const status = hasErrors ? "error" : hasIssues ? "warning" : "valid"; return ( = ({ : "No diagnostic issues" } ariaExpanded={isExpanded} + style={ + experimentalIcons + ? { + width: "auto", + minWidth: 32, + flexShrink: 0, + transition: motionAllowed ? undefined : "none", + } + : undefined + } >
- {hasIssues ? ( - <> - - {totalDiagnosticsCount} - + {experimentalIcons ? ( + ) : ( - + + )} + {!experimentalIcons && hasIssues && ( + {totalDiagnosticsCount} )}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx index a478d1007ce..bd3c5eb2788 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx @@ -10,6 +10,10 @@ import { import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; +import { + AddPlaceIcon, + AddTransitionIcon, +} from "../../../../experimental-icons"; import { writeDraggedNodeKind } from "../../../shared/canvas-node-drag"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; @@ -73,7 +77,8 @@ export const EditionTools: React.FC<{ const { activeSubnetId } = use(ActiveNetContext); const isRootNet = activeSubnetId === null; const { extensions } = use(SDCPNContext); - const { enableNetComponents } = use(UserSettingsContext); + const { enableNetComponents, enableExperimentalIconPack } = + use(UserSettingsContext); if (isReadOnly) { return null; @@ -94,7 +99,18 @@ export const EditionTools: React.FC<{ writeDraggedNodeKind(event.dataTransfer, "place"); }} > - + {enableExperimentalIconPack ? ( + + ) : ( + + )} - + {enableExperimentalIconPack ? ( + + ) : ( + + )} {isRootNet && extensions.subnets && enableNetComponents && ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx index f5b062b915c..02eff467128 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx @@ -10,6 +10,8 @@ import { type PlaybackSpeed, } from "../../../../../react/playback/context"; import { SimulationContext } from "../../../../../react/simulation/context"; +import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; +import { SettingsIcon } from "../../../../experimental-icons"; import { usePetrinautPresentation } from "../../../shared/presentation-context"; import { ToolbarButton } from "./toolbar-button"; @@ -158,6 +160,7 @@ export const PlaybackSettingsMenu = ({ const presentation = usePetrinautPresentation(); const triggerRef = useRef(null); const [open, setOpen] = useState(false); + const { enableExperimentalIconPack } = use(UserSettingsContext); const playModesVisible = !presentation.compactControls; const stoppingConditionsVisible = !presentation.compactControls; const speedRows = toSpeedRows(allowedSpeeds); @@ -204,7 +207,11 @@ export const PlaybackSettingsMenu = ({ ariaExpanded={open} onClick={() => setOpen((wasOpen) => !wasOpen)} > - + {enableExperimentalIconPack ? ( + + ) : ( + + )} {open && ( = ({ allowedPlaybackSpeeds, }) => { const presentation = usePetrinautPresentation(); + const experimentalIcons = useExperimentalIconPackEnabled(); const { dt, state: simulationState, reset } = use(SimulationContext); const { @@ -236,7 +241,9 @@ export const SimulationControls: React.FC = ({ disabled={isPlayDisabled} ariaLabel={getPlayPauseAriaLabel()} > - {isPlaybackPlaying ? ( + {experimentalIcons ? ( + + ) : isPlaybackPlaying ? ( ) : ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx index 6d4b36384ad..d6e0396dbe9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx @@ -1,7 +1,7 @@ import { Tooltip } from "@hashintel/ds-components"; import { cva } from "@hashintel/ds-helpers/css"; -import type { ReactNode, Ref } from "react"; +import type { CSSProperties, ReactNode, Ref } from "react"; const buttonStyle = cva({ base: { @@ -50,6 +50,7 @@ interface ToolbarButtonProps { onClick?: () => void; /** Button content (icons, text, etc.) */ children: ReactNode; + style?: CSSProperties; /** Whether the button is in a selected state */ isSelected?: boolean; /** Whether the button appears disabled (lower opacity, but still clickable) */ @@ -80,6 +81,7 @@ export const ToolbarButton: React.FC = ({ tooltip, onClick, children, + style, isSelected = false, disabled = false, ariaLabel, @@ -104,6 +106,7 @@ export const ToolbarButton: React.FC = ({ onClick={onClick} onKeyDown={handleKeyDown} className={buttonStyle({ isSelected, isDisabled: disabled })} + style={style} aria-label={ariaLabel} aria-expanded={ariaExpanded} aria-pressed={isSelected} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx index 38b33458ba2..6ee2958512a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx @@ -13,6 +13,8 @@ import { type EditorState, } from "../../../../../react/state/editor-context"; import { UndoRedoContext } from "../../../../../react/state/undo-redo-context"; +import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; +import { MenuIcon, SidebarIcon } from "../../../../experimental-icons"; import { ModeSelector } from "./mode-selector"; import { RunningExperimentsPopover } from "./running-experiments-popover"; import { VersionHistoryButton } from "./version-history-button"; @@ -87,6 +89,7 @@ export const TopBar: React.FC = ({ const { isLeftSidebarOpen, setLeftSidebarOpen, setSearchOpen } = use(EditorContext); const undoRedo = use(UndoRedoContext); + const { enableExperimentalIconPack } = use(UserSettingsContext); return (
@@ -102,7 +105,17 @@ export const TopBar: React.FC = ({ }} aria-label={isLeftSidebarOpen ? "Collapse sidebar" : "Expand sidebar"} tooltip={isLeftSidebarOpen ? "Collapse sidebar" : "Expand sidebar"} - iconName="sidebar" + {...(enableExperimentalIconPack + ? { + prefix: ( + + ), + } + : { iconName: "sidebar" })} /> = ({ size="sm" variant="ghost" tooltip="Menu" - iconName="bars" + {...(enableExperimentalIconPack + ? { prefix: } + : { iconName: "bars" })} /> } items={menuItems} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-voice-mode-button.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-voice-mode-button.tsx index f7ef2722f4d..96edc910953 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-voice-mode-button.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-voice-mode-button.tsx @@ -1,27 +1,38 @@ import { Button } from "@hashintel/ds-components"; +import { + VoiceIcon, + useExperimentalIconPackEnabled, +} from "../../../experimental-icons"; + type AiVoiceModeButtonProps = { className?: string; onClick: () => void; size: "sm" | "lg"; }; -export const AiVoiceModeIcon = ({ size = 16 }: { size?: number }) => ( - -); +export const AiVoiceModeIcon = ({ size = 16 }: { size?: number }) => { + const experimentalIcons = useExperimentalIconPackEnabled(); + if (experimentalIcons) { + return ; + } + return ( + + ); +}; export const AiVoiceModeButton = ({ className, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index ec2992edf28..3e0c671ce61 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -41,6 +41,7 @@ import { willShowWalkthroughDialog, } from "../../components/walkthrough/walkthrough-context"; import { WalkthroughDialog } from "../../components/walkthrough/walkthrough-dialog"; +import { ExperimentalIconProvider } from "../../experimental-icons"; import { exportSDCPN } from "../../file-io/export-sdcpn"; import { exportTikZ } from "../../file-io/export-tikz"; import { importSDCPN } from "../../file-io/import-sdcpn"; @@ -193,6 +194,8 @@ export const EditorView = ({ const { brunchDemoMode, enableNotebookView, + enableExperimentalIconPack, + showAnimations, showWalkthroughOnInit, setShowWalkthroughOnInit, } = use(UserSettingsContext); @@ -501,7 +504,10 @@ export const EditorView = ({ isEmptySDCPN(petriNetDefinition); return ( - <> + - + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx index 8af63912bdf..893093fa058 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx @@ -270,6 +270,7 @@ const TestProviders = ({ setShowAnimations: () => {}, setKeepPanelsMounted: () => {}, setCompactNodes: () => {}, + setEnableExperimentalIconPack: () => {}, setArcRendering: () => {}, setCursorMode: () => {}, setIsLeftSidebarOpen: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/microphone-icon.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/microphone-icon.tsx index 2ad3eb742cc..1311bec191f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/microphone-icon.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/microphone-icon.tsx @@ -1,39 +1,47 @@ -/** - * The shared icon set has no microphone, and the dock needs the muted variant - * to be the same drawing with a stroke through it — anything else reads as a - * different control rather than the same one turned off. - */ -export const MicrophoneIcon = ({ muted = false }: { muted?: boolean }) => ( - + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-input-provenance.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-input-provenance.tsx index 2cc4c114fcd..08b71415555 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-input-provenance.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-input-provenance.tsx @@ -1,5 +1,10 @@ import { css } from "@hashintel/ds-helpers/css"; +import { + VoiceIcon, + useExperimentalIconPackEnabled, +} from "../../../../../experimental-icons"; + // Sits in the text flow ahead of the words it belongs to, so a spoken turn is // marked where reading starts rather than by something trailing the bubble. const chipStyle = css({ @@ -19,23 +24,30 @@ const chipStyle = css({ whiteSpace: "nowrap", }); -export const VoiceInputProvenance = () => ( - - - Voice - -); +export const VoiceInputProvenance = () => { + const experimentalIcons = useExperimentalIconPackEnabled(); + return ( + + {experimentalIcons ? ( + + ) : ( + + )} + Voice + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx index b8a01fa3564..48cc6c8196c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx @@ -5,6 +5,10 @@ import { getDocumentUri } from "@hashintel/petrinaut-core"; import { usePetrinautMutations } from "../../../react/hooks/use-petrinaut-mutations"; import { useIsReadOnly } from "../../../react/state/use-is-read-only"; +import { + ChevronRightIcon, + useExperimentalIconPackEnabled, +} from "../../experimental-icons"; import { useDraftField } from "../../hooks/use-draft-field"; import { CodeEditor } from "../../monaco/code-editor"; import { focusLands } from "../../worksheet/focus-flow"; @@ -1372,6 +1376,7 @@ export const NotebookCell: React.FC = ({ bodyParts, onFocusSearch, }) => { + const experimentalIcons = useExperimentalIconPackEnabled(); const { iconColor, name, summary, body } = cellPresentation( net, cell, @@ -1428,9 +1433,13 @@ export const NotebookCell: React.FC = ({ onSetExpanded(!isExpanded); }} > - - - + {experimentalIcons ? ( + + ) : ( + + + + )} = ({ viewportActions }) => { const presentation = usePetrinautPresentation(); const navigation = usePetrinautNavigation(); + const { enableExperimentalIconPack } = use(UserSettingsContext); const isSettingsOpen = navigation.state.overlay?.type === "viewport-settings"; const setIsSettingsOpen = (open: boolean) => { navigation.navigate( @@ -127,7 +130,17 @@ export const ViewportControls: React.FC<{ aria-label="Settings" tooltip="Settings" tooltipOptions={{ position: "left" }} - iconName="gear" + {...(enableExperimentalIconPack + ? { + prefix: ( + + ), + } + : { iconName: "gear" })} className={chromeBackground} onClick={() => setIsSettingsOpen(true)} /> diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx index f03d2e667ff..4f0418ddcf1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx @@ -94,6 +94,8 @@ export const ViewportSettingsDialog: React.FC = ({ setKeepPanelsMounted, compactNodes, setCompactNodes, + enableExperimentalIconPack, + setEnableExperimentalIconPack, arcRendering, setArcRendering, showMinimap, @@ -138,6 +140,24 @@ export const ViewportSettingsDialog: React.FC = ({

Viewport

+ + Petricon{" "} + + Experimental + + + } + description="Animated SVG icons made for Petrinaut." + > + + renderer +provider -> renderer +renderer -> geometry +renderer -> motion +geometry -> output +motion -> output diff --git a/libs/@local/petrinaut-arch-docs/content/index.mdx b/libs/@local/petrinaut-arch-docs/content/index.mdx index a0d18bb3bb6..4c072850fb8 100644 --- a/libs/@local/petrinaut-arch-docs/content/index.mdx +++ b/libs/@local/petrinaut-arch-docs/content/index.mdx @@ -69,6 +69,8 @@ How a host application connects its own document store to the editor. worked adapter. - [Handle contract gaps](doc:handle/contract-gaps): where the contract under-specifies or misbehaves, and the enhancement each gap calls for. +- [Petricon](doc:ui/petricon): icon design references, motion rules, SVG + implementation, and the React API. ## The packages at a glance diff --git a/libs/@local/petrinaut-arch-docs/content/ui/petricon.mdx b/libs/@local/petrinaut-arch-docs/content/ui/petricon.mdx new file mode 100644 index 00000000000..13c8c506db8 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/ui/petricon.mdx @@ -0,0 +1,229 @@ +--- +title: Petricon +description: The icon library's design references, motion rules, SVG implementation, and React API. +attachTo: ui.petricon +sidebar_order: 15 +--- + +Petricon supplies Petrinaut's SVG icons and their motion. Applications pass the +control's state to the icon; its existing parts move into the corresponding +shape. The [website playground](layer:website.petricon) at `/petricon` exposes +the collection, weight controls, state transitions, and alternative designs. + +## Design references + +The drawings are authored for Petrinaut. These references inform the system: + +| Reference | Principle used in Petricon | +| ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| [Apple SF Symbols](https://developer.apple.com/sf-symbols/) | Named layers, configurable effects, and continuity between related states. | +| [Lucide](https://lucide.dev/) | A consistent SVG grid, adjustable strokes, and simple silhouettes. | +| [Material Symbols](https://developers.google.com/fonts/docs/material_symbols) | Weight and fill as parameters rather than separate assets. | +| [STIX](https://www.stixfonts.org/) | Familiar mathematical notation for operators, bounds, and scientific interfaces. | +| [SF Symbols Draw](https://developer.apple.com/videos/play/wwdc2025/337/) | Ordered strokes, staggered drawing, and controlled drawing progress. | +| [Material motion](https://github.com/material-components/material-components-android/blob/master/docs/theming/Motion.md) | Shared timing and easing, with emphasis matched to the action. | + +SF Symbols' variable rendering and Material Symbols' font axes are references, +not Petricon APIs. Petricon uses SVG geometry and browser animation APIs. +Its `weight` changes stroke width continuously; it does not load a variable font. + +## Drawing rules + +Every icon uses a `0 0 24 24` view box and inherits `currentColor`. A regular +stroke is two units wide. Weight is clamped to 100–700 and maps to a stroke +width of one to three units. `size` scales the SVG viewport. Rounded caps keep +short strokes legible; geometry such as gear teeth can override the join. + +Keep a recognisable silhouette at 16 pixels. Animate a detail inside a steady +frame when the frame explains the control. The Edit icon's resting parts share +aligned cells in both selected and unselected states. Its square stays upright; +the triangle and diamond change orientation within the same bounds. + +`variant="filled"` applies the icon's authored filled treatment. Open line +symbols remain strokes. It is not an automatic interpolation between arbitrary +outline and filled drawings. + +## Three kinds of motion + +| Kind | Trigger | Behaviour | +| ---------------- | ------------------------------------------------- | ---------------------------------------------------------------------- | +| Hint | Hover or keyboard focus on the containing control | A small directional nudge, moving detail, or one short sequence. | +| State transition | A changed state prop | Existing geometry moves to a stable arrangement. | +| Effect | A changed `trigger`, or `active` for a loop | An authored action, bounce, pulse, rotation, or supported draw effect. | + +`petriconHints` assigns a motion to every icon name. Type checking rejects a +new icon that has no assignment. Navigation follows direction; syntax brackets +separate; structured values respond in a short sequence; character eyes blink. +The catalog contains no unnamed default hover treatment. + +Hints use small displacements and settle quickly. Generic hints transition in +up to 160 milliseconds; detail sequences run for up to 480 milliseconds. The +flask's liquid starts flat, waves once on hover, and returns flat while still +hovered. Parameter knobs scrub in opposite directions and return to their +initial values. Continuous motion is reserved for an explicitly active effect +or a loading indicator. + +Automatic click feedback lasts up to 420 milliseconds and uses the same details +as the hint. A containing control supplies the event; the icon never performs +the control's action. An explicit effect suppresses automatic click playback +so a React trigger cannot play the response twice. `interaction="none"` opts out. +History has a balanced circular frame, a return arrow, and hands that rewind. +The frame stays in place while its arrow and hands give a brief backward gesture. + +States preserve the control's meaning. The open sidebar divider moves left on +hover to suggest closing. Its collapsed line moves right to suggest expanding. +Settings turns by 30 degrees while its menu is open. Diagnostics reuse two +strokes for check, exclamation mark, and cross; each stroke changes position, +angle, and visible length without crossfading between separate icons. + +## Rendering and ownership + +![How Petricon combines state, geometry, and motion](@diagrams/petricon-rendering.svg) + +The public `petricon.ts` module exports `Petricon`, `PetriconProvider`, the typed +catalog, and design studies. It delegates to the SVG renderer in +`experimental-icons.tsx`. Existing `ExperimentalIcon` exports remain aliases +for compatibility with earlier integrations. + +The geometry and motion helpers live in `ui/experimental-icons/`: + +- Geometry modules describe paths, groups, frames, and moving details. +- `shared/petricon-hints.ts` declares the complete set of hover behaviours. +- `interaction.tsx` applies those behaviours in buttons, links, menus, + checkboxes, segmented controls, and preview tiles. +- `motion.tsx` owns timing, reduced-motion subscription, and Web Animations + API effect lifetimes. Re-triggering cancels the previous effect; unmounting + releases its animations. +- `motion/action-motion.ts` defines semantic detail responses and handles + automatic click feedback through the same effect runner. +- `modeling-geometry.tsx` authors the modeling alternatives and normalized + draw targets. `shared/cube-projection.ts` projects the subnet cube's vertices + and culls edges whose two adjacent faces both point away from the viewer. +- `panda-preset.ts` supplies shared keyframes to the package and host builds. + +Most state changes use CSS transforms and transitions. Diagnostics animate +stroke dash lengths so the same two paths can form all three symbols. The +flask uses SVG `animate` elements with matching path commands to deform its +liquid surface inside a unique clip path. Component-local IDs prevent masks +and clips in repeated icons from interfering. + +The cube rotates in three dimensions and renders the result as ordinary SVG +paths. A short animation frame loop updates only its projected edges, without +re-rendering React on each frame. Hover turns it by 20 degrees; leaving returns +it to the nearest resting orientation. Click makes a quarter-turn. The loop +is cancelled on interruption, unmount, or a change to the motion preference. +Hidden edges are removed from rendering, rather than faded through the cube. + +The diagnostics badge owns the number and layout around the icon. It retains +the last positive count during exit, moves that count through a gradient mask, +and animates the count window's width. The button and toolbar follow this +width through normal layout. Screen readers receive the current count from +the button label even while the previous visible count is leaving. + +## Use the React API + +```tsx +import { Petricon, PetriconProvider } from "@hashintel/petrinaut/ui"; +import "@hashintel/petrinaut/styles.css"; + + + +; +``` + +The provider supplies default size, weight, color, duration, and motion policy. +Set `enabled` to use `petriconPack` through the design system's `IconProvider`. +Petrinaut's **Petricon (Experimental)** preference does this for the editor and +keeps the existing stored feature-flag key. + +| Icon | State prop | +| ------------------------------------------------------- | ---------------------------------------- | +| `sidebar` | `collapsed` | +| `settings`, `menu` | `open` | +| `playback` | `playing` | +| `microphone` | `muted` | +| `diagnostics` | `status`: `valid`, `warning`, or `error` | +| `shapes`, `flask`, `layer`, `addPlace`, `addTransition` | `selected` | + +An explicit effect is separate from the state: + +```tsx +; +; +``` + +Use `getPetriconEffects(name)` to list supported effects. Draw effects have +authored targets on add-place/add-transition, history/reset, parameters, the cube, +and all modeling alternatives. SVG `pathLength="1"` gives each stroke a normalized +length, so the effect can trace differently sized paths with the same timing. +Choose `choreography="together"`, `"stagger"`, or `"sequential"`. + +```tsx +; +; +; +``` + +`drawProgress` controls the revealed proportion of every authored stroke. It is +clamped to 0–1, with non-finite values resolved to 1, and overrides timed drawing. +It represents a value, so it is preserved with reduced motion. Omit it to release +the override. This uses SVG stroke dash lengths; it does not require a font or +raster frames. `transition` +accepts `smooth`, `spring`, and `none`. Morphing an arbitrary pair of icon names +is not supported; the add-place/add-transition pair shares an authored frame. + +`petriconCatalog` exposes names, labels, categories, motion assignments, and study +membership. `petriconStudies` groups multiple variations per concept with their +intended uses. Math studies cover summation, integration, matrices, and +probability. Code studies cover expressions, types, breakpoints, and tests. +AI studies cover agents, context, reasoning, and embeddings. They are available +by name without assigning them to new editor actions. + +Modeling studies offer three variations per concept: + +| Concept | Alternatives | Motion | +| ---------------------- | ---------------------------------------- | --------------------------------------------------------------------------- | +| Differential equations | Derivative, solution curve, slope field | Open the fraction, evolve the curve, or bend the field around a trajectory. | +| Parameters | Sliders, calibration dial, bounds | Scrub, turn the needle, or narrow the range; return to initial values. | +| Variables | Script x, bounded expression, register | Flex the value or shift it inside a fixed container. | +| Token types | Token family, collection stack, type tag | Gather tokens, separate layers, or respond inside a label. | +| Subnets | Cube, contained net, nested modules | Rotate with hidden-edge removal, trace connections, or separate modules. | + +The website's Modeling tab compares these alternatives with Action and Draw on +playback. The inspector also exposes drawing order and a progress slider. + +## Accessibility and verification + +Icons are decorative by default. Give the button an accessible label. Use an +icon's `aria-label` when the SVG itself carries independent meaning. Hover +hints respond to keyboard focus on supported controls. Disabled controls +suppress hints. `motion="none"`, Petrinaut's **Animations** setting, and the +system reduced-motion preference preserve the final state while removing +effects and transitions. + +When adding an icon, author its geometry, add its name to `petriconHints`, and +classify it in the catalog. Add a moving-detail marker when the action belongs +to one part. Reuse paths when implementing a state transition. Inspect the +icon at small sizes and weights at both ends of the range, then check hover, +keyboard focus, press, state changes, and motion disabled. + +The Storybook **Petrinaut / Petricon / Catalog** and website `/petricon` use the +same renderer. Run the Petrinaut and website build, unit tests, type checks, +and lint. Architecture changes also require the architecture checker; authored +MDX and D2 changes require the docs-site build. From a63cab2aa8ca4bf09b552af8ced10504ba012bcd Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 15 Sep 2026 16:58:32 +0200 Subject: [PATCH 2/4] Animate the running-person icon on hover and click --- .../petrinaut/docs/visual-settings.md | 4 + libs/@hashintel/petrinaut/src/panda-preset.ts | 24 ++++++ .../src/ui/experimental-icons.stories.tsx | 9 ++- .../src/ui/experimental-icons.test.tsx | 79 +++++++++++++++++++ .../petrinaut/src/ui/experimental-icons.tsx | 1 + .../src/ui/experimental-icons/interaction.tsx | 22 ++++++ .../motion/action-motion.ts | 31 +++++++- .../shared/petricon-hints.ts | 2 +- .../experimental-icons/utility-geometry.tsx | 14 +++- 9 files changed, 175 insertions(+), 11 deletions(-) diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index c98627b0019..e61206fcc47 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -48,6 +48,10 @@ meaning. Controls with a persistent state keep their existing state animation. briefly rewinds the hands. The subnet cube turns in depth on hover and makes a quarter-turn when clicked; rear edges stay hidden throughout the turn. +The running-person icon runs in place while you hover over its control or focus +it with the keyboard. Clicking makes it jump, then land and continue running +until you move away or move keyboard focus elsewhere. + The AI assistant button uses a robot head. Hovering makes its eyes blink and its antenna move briefly before settling. diff --git a/libs/@hashintel/petrinaut/src/panda-preset.ts b/libs/@hashintel/petrinaut/src/panda-preset.ts index 30380652304..747a78ef8c0 100644 --- a/libs/@hashintel/petrinaut/src/panda-preset.ts +++ b/libs/@hashintel/petrinaut/src/panda-preset.ts @@ -40,6 +40,30 @@ export const petrinautPandaPreset = { }, }, keyframes: { + petriconRunnerBob: { + "0%, 50%, 100%": { transform: "translateY(0)" }, + "25%, 75%": { transform: "translateY(-0.7px)" }, + }, + petriconRunnerBackArm: { + "0%, 100%": { transform: "rotate(0deg)" }, + "50%": { transform: "rotate(-100deg)" }, + }, + petriconRunnerFrontArm: { + "0%, 100%": { transform: "rotate(0deg)" }, + "50%": { transform: "rotate(100deg)" }, + }, + petriconRunnerBackLeg: { + "0%, 100%": { d: 'path("M10 15L6 19L2 19")' }, + "25%": { d: 'path("M10 15L6 16L5 12")' }, + "50%": { d: 'path("M10 15L15 18L14 22")' }, + "75%": { d: 'path("M10 15L10 20L6 22")' }, + }, + petriconRunnerFrontLeg: { + "0%, 100%": { d: 'path("M10 15L15 18L14 22")' }, + "25%": { d: 'path("M10 15L10 20L6 22")' }, + "50%": { d: 'path("M10 15L6 19L2 19")' }, + "75%": { d: 'path("M10 15L6 16L5 12")' }, + }, petriconSequence: { "0%, 100%": { transform: "translateY(0)", opacity: "1" }, "40%": { transform: "translateY(-1px)", opacity: "0.72" }, diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons.stories.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons.stories.tsx index 5adcd92bcf2..5dde16537ec 100644 --- a/libs/@hashintel/petrinaut/src/ui/experimental-icons.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons.stories.tsx @@ -609,7 +609,12 @@ const Gallery = () => { />
{names.map((name) => ( -
+
+ ))}

diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx index 5766a6d1414..4e4d4ee34a6 100644 --- a/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx @@ -457,6 +457,85 @@ describe("experimental icon motion", () => { expect(cancel).toHaveBeenCalledTimes(4); }); + it("jumps on click without replacing the runner's independent gait", () => { + const { container, unmount } = render( + , + ); + const button = screen.getByRole("button"); + const runner = container.querySelector('[data-icon-detail="runner"]'); + expect(runner?.querySelectorAll("[data-runner-part]")).toHaveLength(5); + expect(container.querySelector('[data-hover="true"]')).not.toBeNull(); + expect(container.querySelector("svg")?.style.overflow).toBe("visible"); + expect(animate).not.toHaveBeenCalled(); + + fireEvent.click(button); + expect(animate).toHaveBeenCalledTimes(1); + expect(animate.mock.contexts.at(-1)).toBe(runner); + expect(animate.mock.calls.at(-1)).toEqual([ + expect.arrayContaining([ + expect.objectContaining({ transform: "translateY(1px) scaleY(.9)" }), + expect.objectContaining({ transform: "translateY(-4px) scaleY(1.04)" }), + expect.objectContaining({ transform: "translateY(.6px) scaleY(.94)" }), + ]), + expect.objectContaining({ + iterations: 1, + fill: "none", + easing: "linear", + }), + ]); + + fireEvent.click(button); + expect(cancel).toHaveBeenCalledTimes(1); + expect(animate).toHaveBeenCalledTimes(2); + unmount(); + expect(cancel).toHaveBeenCalledTimes(2); + }); + + it.each(["disabled", "motion", "interaction"])( + "keeps the runner still with the %s opt-out", + (optOut) => { + const { container } = render( + , + ); + const button = screen.getByRole("button"); + fireEvent.pointerEnter(button); + fireEvent.focus(button); + fireEvent.click(button); + expect(animate).not.toHaveBeenCalled(); + if (optOut !== "disabled") { + expect(container.querySelector('[data-hover="true"]')).toBeNull(); + } + }, + ); + + it("stops the runner's jump and gait when reduced motion is requested", () => { + const { container } = render( + , + ); + fireEvent.click(screen.getByRole("button")); + expect(animate).toHaveBeenCalledTimes(1); + act(() => { + reducedMotion = true; + mediaListeners.forEach((listener) => listener()); + }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(container.querySelector('[data-hover="true"]')).toBeNull(); + expect(container.querySelector('[data-icon-motion="none"]')).not.toBeNull(); + fireEvent.click(screen.getByRole("button")); + expect(animate).toHaveBeenCalledTimes(1); + }); + it("suppresses automatic interaction for disabled controls and motion opt-outs", () => { const { rerender } = render( , + ); + const animationCount = animate.mock.calls.length; + const frameCount = requestFrame.mock.calls.length; + expect(animationCount + frameCount).toBeGreaterThan(0); + const button = screen.getByRole("button"); + fireEvent.pointerEnter(button); + fireEvent.focus(button); + fireEvent.pointerLeave(button); + fireEvent.blur(button); + expect(cancel).not.toHaveBeenCalled(); + expect(cancelFrame).not.toHaveBeenCalled(); + expect(animate).toHaveBeenCalledTimes(animationCount); + expect(requestFrame).toHaveBeenCalledTimes(frameCount); + + rerender( + , + ); + expect( + cancel.mock.calls.length + cancelFrame.mock.calls.length, + ).toBeGreaterThan(0); + animate.mockClear(); + requestFrame.mockClear(); + fireEvent.pointerEnter(button); + expect( + animate.mock.calls.length + requestFrame.mock.calls.length, + ).toBeGreaterThan(0); + }, + ); + it("draws the frame and corner badge in order for both variants", () => { const { rerender, container } = render( { const targets = @@ -170,7 +173,7 @@ export const useIconEffects = ({ ); }; const enter = () => { - if (!authoredHover || isIconControlDisabled(control)) return; + if (!hoverEnabled || isIconControlDisabled(control)) return; if (cube) { cancelInteraction.current(); const angle = Number(cube.getAttribute("data-cube-angle") ?? 35); @@ -190,7 +193,7 @@ export const useIconEffects = ({ ); }; const leave = () => { - if (!cube || !authoredHover) return; + if (!cube || !hoverEnabled) return; cancelInteraction.current(); const angle = Number(cube.getAttribute("data-cube-angle") ?? 35); cancelInteraction.current = animateCube( @@ -213,7 +216,7 @@ export const useIconEffects = ({ control.removeEventListener("focus", enter); control.removeEventListener("blur", leave); }; - }, [allowed, authoredHover, interactive, effectKey, name, duration]); + }, [allowed, hoverEnabled, interactive, effectKey, name, duration]); useEffect(() => { const triggered = previousTrigger.current !== trigger; diff --git a/libs/@local/petrinaut-arch-docs/content/ui/petricon.mdx b/libs/@local/petrinaut-arch-docs/content/ui/petricon.mdx index 13c8c506db8..b174f4b45ba 100644 --- a/libs/@local/petrinaut-arch-docs/content/ui/petricon.mdx +++ b/libs/@local/petrinaut-arch-docs/content/ui/petricon.mdx @@ -60,13 +60,15 @@ Hints use small displacements and settle quickly. Generic hints transition in up to 160 milliseconds; detail sequences run for up to 480 milliseconds. The flask's liquid starts flat, waves once on hover, and returns flat while still hovered. Parameter knobs scrub in opposite directions and return to their -initial values. Continuous motion is reserved for an explicitly active effect -or a loading indicator. +initial values. The running-person icon runs in place while hovered or +keyboard-focused and jumps on click; leaving the control stops its gait. +Other continuous motion requires an explicitly active effect or loading icon. Automatic click feedback lasts up to 420 milliseconds and uses the same details as the hint. A containing control supplies the event; the icon never performs the control's action. An explicit effect suppresses automatic click playback -so a React trigger cannot play the response twice. `interaction="none"` opts out. +so a React trigger cannot play the response twice. Authored hover actions do not +interrupt an explicitly active action loop. `interaction="none"` opts out. History has a balanced circular frame, a return arrow, and hands that rewind. The frame stays in place while its arrow and hands give a brief backward gesture. From 2c6f4fe21cf62e0d3d118061dde94ed28aad4d6f Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 15 Sep 2026 17:53:16 +0200 Subject: [PATCH 4/4] Preserve nested icon preferences and cube turns --- libs/@hashintel/petrinaut/README.md | 4 +- .../petrinaut/docs/visual-settings.md | 3 +- .../src/ui/experimental-icons.test.tsx | 99 +++++++++++++++++++ .../petrinaut/src/ui/experimental-icons.tsx | 9 +- .../src/ui/experimental-icons/motion.tsx | 22 ++++- .../motion/action-motion.ts | 10 ++ .../shared/cube-projection.ts | 2 + 7 files changed, 141 insertions(+), 8 deletions(-) diff --git a/libs/@hashintel/petrinaut/README.md b/libs/@hashintel/petrinaut/README.md index f2803f173e2..a5ef4755c56 100644 --- a/libs/@hashintel/petrinaut/README.md +++ b/libs/@hashintel/petrinaut/README.md @@ -123,8 +123,8 @@ The provider sets size, weight, color, motion, and duration defaults for custom the experimental pack to design-system components in its tree, including portals. Design-system `Icon` sizes continue to follow their existing size tokens. Use `enabled={false}` to restore the default design-system pack. Explicit custom -icon components still render their requested icon. Nested providers inherit defaults -and can override individual values. +icon components still render their requested icon. Nested providers inherit defaults, +including `enabled`, and can override individual values. `experimentalIconNames` lists the custom icon names. `experimentalIconPack` maps design-system names to replacements and can also be passed to the design-system diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index 36e69812397..e3a2627d5e4 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -46,7 +46,8 @@ meaning. Controls with a persistent state keep their existing state animation. **Recent changes** uses a clock with a return arrow. Hovering or opening its menu briefly rewinds the hands. The subnet cube turns in depth on hover and makes a -quarter-turn when clicked; rear edges stay hidden throughout the turn. +quarter-turn when clicked. The click turn finishes even if you move the pointer +or keyboard focus away; rear edges stay hidden throughout the turn. The running-person icon runs in place while you hover over its control or focus it with the keyboard. Clicking makes it jump, then land and continue running diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx index 3682e993048..ec27811db03 100644 --- a/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons.test.tsx @@ -174,6 +174,37 @@ describe("experimental SVG API", () => { expect(decorative?.getAttribute("fill")).toBe("currentColor"); }); + it.each([ + { parentEnabled: false, enabled: undefined, expected: false }, + { parentEnabled: true, enabled: undefined, expected: true }, + { parentEnabled: false, enabled: true, expected: true }, + { parentEnabled: true, enabled: false, expected: false }, + { parentEnabled: undefined, enabled: undefined, expected: true }, + ])( + "resolves nested enabled=$enabled with parent enabled=$parentEnabled to $expected", + ({ parentEnabled, enabled, expected }) => { + const Sample = ({ parent }: { parent?: boolean }) => ( + + + + + + + ); + const { rerender } = render(); + const expectEnabled = (isEnabled: boolean) => { + for (const name of ["Play", "Assistant"]) { + expect( + screen.getByRole("img", { name }).getAttribute("data-icon-pack"), + ).toBe(isEnabled ? "petrinaut-experimental" : null); + } + }; + expectEnabled(expected); + rerender(); + expectEnabled(enabled ?? !parentEnabled); + }, + ); + it.each([ [0, 1], [800, 3], @@ -696,6 +727,74 @@ describe("experimental icon motion", () => { expect(cube?.getAttribute("data-cube-angle")).toBe("35"); }); + it.each([ + { exit: "pointerLeave", triggered: false }, + { exit: "blur", triggered: false }, + { exit: "pointerLeave", triggered: true }, + { exit: "blur", triggered: true }, + ] as const)( + "finishes a cube turn after $exit with triggered=$triggered", + ({ exit, triggered }) => { + const frames = new Map(); + let frameId = 0; + vi.stubGlobal( + "requestAnimationFrame", + (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }, + ); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); + const tick = (time: number) => + act(() => { + const pending = [...frames.values()]; + frames.clear(); + pending.forEach((callback) => callback(time)); + }); + const Sample = ({ trigger = 0 }: { trigger?: number }) => ( + + ); + const { container, rerender, unmount } = render(); + const button = screen.getByRole("button"); + const cube = container.querySelector("[data-icon-cube]"); + const enter = exit === "blur" ? "focus" : "pointerEnter"; + fireEvent[enter](button); + tick(0); + tick(100); + const startingAngle = Number(cube?.getAttribute("data-cube-angle")); + if (triggered) rerender(); + else fireEvent.click(button); + tick(110); + tick(120); + fireEvent[exit](button); + fireEvent[enter](button); + fireEvent[exit](button); + expect(frames.size).toBe(1); + tick(1100); + expect(Number(cube?.getAttribute("data-cube-angle"))).toBeCloseTo( + startingAngle + 90, + ); + expect(frames.size).toBe(0); + + fireEvent[enter](button); + tick(1200); + tick(1500); + expect(Number(cube?.getAttribute("data-cube-angle"))).toBe(145); + fireEvent[exit](button); + tick(1600); + tick(1900); + expect(Number(cube?.getAttribute("data-cube-angle"))).toBe(125); + unmount(); + expect(frames.size).toBe(0); + }, + ); + it("clamps drawing progress, preserves it with motion disabled, and releases the override", () => { const { container, rerender } = render( = { const defaultIconPack: IconPack = {}; export const ExperimentalIconProvider = ({ - enabled = true, + enabled, children, ...defaults }: PropsWithChildren) => { const parentDefaults = use(ExperimentalIconContext); + const resolvedEnabled = enabled ?? parentDefaults.enabled ?? true; return ( - + {children} diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion.tsx b/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion.tsx index 952207d3982..b7abee789bb 100644 --- a/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion.tsx +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion.tsx @@ -127,6 +127,7 @@ export const useIconEffects = ({ const ref = useRef(null); const previousTrigger = useRef(trigger); const cancelInteraction = useRef<() => void>(() => {}); + const cubeActionInProgress = useRef(false); const effectKey = ( typeof effect === "string" ? [effect] : (effect ?? []) ).join(","); @@ -170,10 +171,19 @@ export const useIconEffects = ({ Math.min(duration, 420), false, cancelInteraction.current, + () => { + cubeActionInProgress.current = false; + }, ); + cubeActionInProgress.current = Boolean(cube); }; const enter = () => { - if (!hoverEnabled || isIconControlDisabled(control)) return; + if ( + !hoverEnabled || + cubeActionInProgress.current || + isIconControlDisabled(control) + ) + return; if (cube) { cancelInteraction.current(); const angle = Number(cube.getAttribute("data-cube-angle") ?? 35); @@ -193,7 +203,7 @@ export const useIconEffects = ({ ); }; const leave = () => { - if (!cube || !hoverEnabled) return; + if (!cube || !hoverEnabled || cubeActionInProgress.current) return; cancelInteraction.current(); const angle = Number(cube.getAttribute("data-cube-angle") ?? 35); cancelInteraction.current = animateCube( @@ -209,6 +219,7 @@ export const useIconEffects = ({ control.addEventListener("blur", leave); return () => { cancelInteraction.current(); + cubeActionInProgress.current = false; if (cube) paintCube(cube, 35); control.removeEventListener("click", play); control.removeEventListener("pointerenter", enter); @@ -245,6 +256,12 @@ export const useIconEffects = ({ totalDuration, active === true, cancelInteraction.current, + () => { + cubeActionInProgress.current = false; + }, + ); + cubeActionInProgress.current = Boolean( + root.querySelector("[data-icon-cube]"), ); cancelInteraction.current = cancelAction; continue; @@ -326,6 +343,7 @@ export const useIconEffects = ({ } return () => { cancelAction(); + if (effects.includes("action")) cubeActionInProgress.current = false; animations.forEach((animation) => animation.cancel()); }; }, [ diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion/action-motion.ts b/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion/action-motion.ts index a9a27d4af34..faf33df1ec3 100644 --- a/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion/action-motion.ts +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/motion/action-motion.ts @@ -89,6 +89,7 @@ export const playIconAction = ( duration: number, loop = false, cancelPrevious: () => void = () => {}, + onComplete?: () => void, ) => { const details = root.querySelectorAll("[data-icon-detail]"); const previous = new Map( @@ -111,6 +112,7 @@ export const playIconAction = ( cube, Number(cube.getAttribute("data-cube-angle") ?? 35) + 90, duration, + loop ? undefined : onComplete, ); }; turn(); @@ -214,6 +216,14 @@ export const playIconAction = ( ]); } let cancelled = false; + if (!loop && onComplete) { + void Promise.all(animations.map((animation) => animation.finished)).then( + () => { + if (!cancelled) onComplete(); + }, + () => {}, + ); + } return () => { if (cancelled) return; cancelled = true; diff --git a/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.ts b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.ts index 3ca0bc73d65..ac4cc86eb3a 100644 --- a/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.ts +++ b/libs/@hashintel/petrinaut/src/ui/experimental-icons/shared/cube-projection.ts @@ -72,6 +72,7 @@ export const animateCube = ( root: Element, target: number, duration: number, + onComplete?: () => void, ) => { const start = Number(root.getAttribute("data-cube-angle") ?? 35); let frame = 0; @@ -82,6 +83,7 @@ export const animateCube = ( const eased = 1 - (1 - progress) ** 3; paintCube(root, start + (target - start) * eased); if (progress < 1) frame = requestAnimationFrame(tick); + else onComplete?.(); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame);