+
+
Operations
@@ -222,6 +240,10 @@
+
diff --git a/src/web/stylesheets/index.css b/src/web/stylesheets/index.css
index 0565399f..6962d7ae 100755
--- a/src/web/stylesheets/index.css
+++ b/src/web/stylesheets/index.css
@@ -34,6 +34,7 @@
@import "./layout/_operations.css";
@import "./layout/_recipe.css";
@import "./layout/_structure.css";
+@import "./layout/_responsive.css";
/* Operations */
@import "./operations/diff.css";
diff --git a/src/web/stylesheets/layout/_responsive.css b/src/web/stylesheets/layout/_responsive.css
new file mode 100644
index 00000000..176f4766
--- /dev/null
+++ b/src/web/stylesheets/layout/_responsive.css
@@ -0,0 +1,382 @@
+/**
+ * Responsive phone / tablet layout for CyberChef.
+ * Driven by [data-layout] on #content-wrapper (set via ResizeObserver).
+ *
+ * @author blepman
+ * @license Apache-2.0
+ */
+
+/* ---- Chrome defaults (hidden on desktop) ---- */
+
+.responsive-chrome,
+.mobile-only,
+.tablet-only,
+.responsive-only {
+ display: none !important;
+}
+
+#ops-overlay {
+ display: none;
+}
+
+/* ---- Shared non-desktop ---- */
+
+[data-layout="mobile"] .responsive-only,
+[data-layout="tablet"] .responsive-only {
+ display: inline-flex !important;
+}
+
+[data-layout="mobile"] .gutter,
+[data-layout="tablet"] .gutter {
+ display: none !important;
+}
+
+[data-layout="mobile"] #workspace-wrapper,
+[data-layout="tablet"] #workspace-wrapper {
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+}
+
+[data-layout="mobile"] #operations.split,
+[data-layout="mobile"] #recipe.split,
+[data-layout="mobile"] #IO.split,
+[data-layout="tablet"] #operations.split,
+[data-layout="tablet"] #recipe.split,
+[data-layout="tablet"] #IO.split {
+ float: none;
+ width: auto;
+ height: auto;
+}
+
+[data-layout="mobile"] #banner .col:first-child,
+[data-layout="tablet"] #banner .col:first-child {
+ display: none;
+}
+
+[data-layout="mobile"] #banner,
+[data-layout="tablet"] #banner {
+ min-height: 40px;
+ align-items: center;
+}
+
+[data-layout="mobile"] #workspace-wrapper,
+[data-layout="tablet"] #workspace-wrapper {
+ top: 40px;
+}
+
+/* Reorder / touch affordances */
+.op-reorder-up,
+.op-reorder-down {
+ cursor: pointer;
+ font-size: 20px !important;
+ margin-left: 2px;
+ min-width: 44px;
+ min-height: 44px;
+ align-items: center;
+ justify-content: center;
+ vertical-align: middle;
+}
+
+.op-list li.operation {
+ position: relative;
+}
+
+/* ---- Tablet (768–1023) ---- */
+
+[data-layout="tablet"] .tablet-only {
+ display: inline-flex !important;
+}
+
+[data-layout="tablet"] #recipe {
+ flex: 1 1 45%;
+ min-width: 0;
+ height: 100%;
+ border-right: 1px solid var(--primary-border-colour);
+}
+
+[data-layout="tablet"] #IO {
+ flex: 1 1 55%;
+ min-width: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+[data-layout="tablet"] #IO #input,
+[data-layout="tablet"] #IO #output {
+ flex: 1 1 50%;
+ width: 100%;
+ height: 50%;
+ min-height: 0;
+}
+
+[data-layout="tablet"] #operations {
+ position: fixed;
+ top: 40px;
+ left: 0;
+ bottom: 0;
+ width: min(360px, 90%);
+ z-index: 1200;
+ background: var(--primary-background-colour);
+ border-right: 1px solid var(--primary-border-colour);
+ transform: translateX(-105%);
+ transition: transform 0.2s ease;
+ box-shadow: 2px 0 12px rgba(0, 0, 0, 0.2);
+}
+
+[data-layout="tablet"].ops-open #operations,
+body[data-layout="tablet"] #content-wrapper.ops-open #operations {
+ transform: translateX(0);
+}
+
+[data-layout="tablet"] #ops-overlay:not([hidden]) {
+ display: block;
+ position: fixed;
+ inset: 40px 0 0 0;
+ z-index: 1100;
+}
+
+[data-layout="tablet"] #ops-scrim {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+}
+
+[data-layout="tablet"] #tablet-ops-toggle {
+ display: inline-flex !important;
+ align-items: center;
+ gap: 4px;
+ margin-right: 8px;
+ min-height: 44px;
+ padding: 0 12px;
+}
+
+[data-layout="tablet"] #data-segment-bar,
+[data-layout="tablet"] #mobile-nav {
+ display: none !important;
+}
+
+/* ---- Mobile (<768) ---- */
+
+[data-layout="mobile"] .mobile-only {
+ display: flex !important;
+}
+
+[data-layout="mobile"] #workspace-wrapper {
+ flex-direction: column;
+ bottom: 56px;
+}
+
+[data-layout="mobile"] #recipe,
+[data-layout="mobile"] #IO {
+ flex: 1 1 auto;
+ width: 100%;
+ min-height: 0;
+ height: 100%;
+ display: none;
+ flex-direction: column;
+}
+
+[data-layout="mobile"][data-mobile-pane="recipe"] #recipe,
+#content-wrapper[data-layout="mobile"][data-mobile-pane="recipe"] #recipe {
+ display: flex;
+}
+
+[data-layout="mobile"][data-mobile-pane="data"] #IO,
+#content-wrapper[data-layout="mobile"][data-mobile-pane="data"] #IO {
+ display: flex;
+}
+
+[data-layout="mobile"] #IO #input,
+[data-layout="mobile"] #IO #output {
+ flex: 1 1 auto;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ display: none;
+ flex-direction: column;
+}
+
+[data-layout="mobile"][data-data-segment="input"] #IO #input,
+#content-wrapper[data-layout="mobile"][data-data-segment="input"] #IO #input {
+ display: flex;
+}
+
+[data-layout="mobile"][data-data-segment="output"] #IO #output,
+#content-wrapper[data-layout="mobile"][data-data-segment="output"] #IO #output {
+ display: flex;
+}
+
+[data-layout="mobile"] #data-segment-bar {
+ display: flex !important;
+ flex: 0 0 auto;
+ gap: 0;
+ border-bottom: 1px solid var(--primary-border-colour);
+ background: var(--secondary-background-colour);
+}
+
+[data-layout="mobile"] #data-segment-bar button {
+ flex: 1;
+ min-height: 44px;
+ border: 0;
+ background: transparent;
+ color: var(--primary-font-colour);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+[data-layout="mobile"] #data-segment-bar button.active {
+ border-bottom: 3px solid var(--primary-font-colour);
+ background: var(--primary-background-colour);
+}
+
+[data-layout="mobile"][data-mobile-pane="recipe"] #data-segment-bar {
+ display: none !important;
+}
+
+/* Sticky Bake bar sits above bottom nav */
+[data-layout="mobile"] #controls,
+[data-layout="mobile"] #controls.mobile-controls-bar {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 56px;
+ z-index: 900;
+ padding: 8px 4px;
+ width: 100%;
+}
+
+[data-layout="mobile"] #rec-list {
+ padding-bottom: 88px;
+}
+
+[data-layout="mobile"] #IO #input-wrapper,
+[data-layout="mobile"] #IO #output-wrapper {
+ padding-bottom: 88px;
+}
+
+[data-layout="mobile"] #operations {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: auto;
+ height: 85dvh;
+ max-height: 85vh;
+ z-index: 1300;
+ background: var(--primary-background-colour);
+ border-radius: 12px 12px 0 0;
+ transform: translateY(105%);
+ transition: transform 0.25s ease;
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.25);
+ width: 100%;
+}
+
+[data-layout="mobile"].ops-open #operations,
+#content-wrapper[data-layout="mobile"].ops-open #operations {
+ transform: translateY(0);
+}
+
+[data-layout="mobile"] #ops-overlay:not([hidden]) {
+ display: block;
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+}
+
+[data-layout="mobile"] #ops-scrim {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+}
+
+[data-layout="mobile"] #ops-sheet-header {
+ display: flex !important;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--primary-border-colour);
+ min-height: 48px;
+}
+
+[data-layout="mobile"] #ops-sheet-header .ops-handle {
+ width: 40px;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--secondary-border-colour);
+ margin: 4px auto 8px;
+}
+
+[data-layout="mobile"] #mobile-nav {
+ display: flex !important;
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 56px;
+ z-index: 1000;
+ background: var(--secondary-background-colour);
+ border-top: 1px solid var(--primary-border-colour);
+ align-items: stretch;
+ justify-content: space-around;
+ padding-bottom: env(safe-area-inset-bottom, 0);
+}
+
+[data-layout="mobile"] #mobile-nav button {
+ flex: 1;
+ border: 0;
+ background: transparent;
+ color: var(--primary-font-colour);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ min-height: 44px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+}
+
+[data-layout="mobile"] #mobile-nav button.active {
+ color: var(--input-highlight-colour, #4caf50);
+}
+
+[data-layout="mobile"] #mobile-nav .material-icons {
+ font-size: 22px;
+}
+
+[data-layout="mobile"] #nav-ops-fab {
+ flex: 0 0 56px;
+ width: 56px;
+ height: 56px;
+ margin-top: -12px;
+ border-radius: 50%;
+ background: var(--primary-font-colour);
+ color: var(--primary-background-colour);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
+
+[data-layout="mobile"] #nav-ops-fab .material-icons {
+ font-size: 28px;
+}
+
+[data-layout="mobile"] #tablet-ops-toggle {
+ display: none !important;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-layout="mobile"] #operations,
+ [data-layout="tablet"] #operations {
+ transition: none;
+ }
+}
+
+/* Fallback when container queries are unsupported: mirror via body attribute already set */
+@supports (container-type: inline-size) {
+ #content-wrapper.cyberchef-root {
+ container-type: inline-size;
+ container-name: cyberchef;
+ }
+}
diff --git a/src/web/waiters/OperationsWaiter.mjs b/src/web/waiters/OperationsWaiter.mjs
index 6c2de064..348f6b99 100755
--- a/src/web/waiters/OperationsWaiter.mjs
+++ b/src/web/waiters/OperationsWaiter.mjs
@@ -229,9 +229,46 @@ class OperationsWaiter {
* @param {event} e
*/
operationDblclick(e) {
- const li = e.target;
+ const li = e.target.closest("li.operation");
+ if (!li) return;
- this.manager.recipe.addOperation(li.textContent);
+ this.manager.recipe.addOperation(this.operationNameFromListItem(li));
+ }
+
+
+ /**
+ * Tap-to-add on phone/tablet layouts (single click).
+ * Desktop keeps double-click / drag-and-drop.
+ *
+ * @param {event} e
+ */
+ operationClick(e) {
+ if (!this.manager.responsive || !this.manager.responsive.isTouchLayout()) {
+ return;
+ }
+
+ // Ignore clicks on remove icons (favourites editor)
+ if (e.target.closest(".remove-icon")) return;
+
+ const li = e.target.closest("li.operation");
+ if (!li || !li.closest(".op-list")) return;
+
+ e.preventDefault();
+ this.manager.recipe.addOperation(this.operationNameFromListItem(li));
+ this.manager.responsive.afterOperationAdded();
+ }
+
+
+ /**
+ * Extract operation name from a list item without icon ligature text.
+ *
+ * @param {Element} li
+ * @returns {string}
+ */
+ operationNameFromListItem(li) {
+ const clone = li.cloneNode(true);
+ clone.querySelectorAll("i").forEach(icon => icon.remove());
+ return clone.textContent.trim();
}
diff --git a/src/web/waiters/OutputWaiter.mjs b/src/web/waiters/OutputWaiter.mjs
index 2b2cb096..a4db8e6d 100755
--- a/src/web/waiters/OutputWaiter.mjs
+++ b/src/web/waiters/OutputWaiter.mjs
@@ -1531,6 +1531,13 @@ class OutputWaiter {
* Resizes the output frame to be as large as possible, or restores it to its original size.
*/
maximiseOutputClick(e) {
+ if (this.manager.responsive && this.manager.responsive.isTouchLayout()) {
+ // On phone/tablet, switch to the Data/Output view instead of Split.js maximise.
+ this.manager.responsive.showMobilePane("data");
+ this.manager.responsive.showDataSegment("output");
+ return;
+ }
+
const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode;
if (el.getAttribute("data-original-title").indexOf("Maximise") === 0) {
diff --git a/src/web/waiters/RecipeWaiter.mjs b/src/web/waiters/RecipeWaiter.mjs
index e4198cd5..73e67f20 100755
--- a/src/web/waiters/RecipeWaiter.mjs
+++ b/src/web/waiters/RecipeWaiter.mjs
@@ -463,6 +463,36 @@ class RecipeWaiter {
}
+ /**
+ * Move a recipe operation one step up (touch layout affordance).
+ *
+ * @param {event} e
+ */
+ moveOpUp(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ const li = e.target.closest("li.operation");
+ if (!li || !li.previousElementSibling) return;
+ li.parentNode.insertBefore(li, li.previousElementSibling);
+ window.dispatchEvent(this.manager.statechange);
+ }
+
+
+ /**
+ * Move a recipe operation one step down (touch layout affordance).
+ *
+ * @param {event} e
+ */
+ moveOpDown(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ const li = e.target.closest("li.operation");
+ if (!li || !li.nextElementSibling) return;
+ li.parentNode.insertBefore(li.nextElementSibling, li);
+ window.dispatchEvent(this.manager.statechange);
+ }
+
+
/**
* Handler for operation dropdown events from toggleString arguments.
* Sets the selected option as the name of the button.
diff --git a/src/web/waiters/ResponsiveLayoutWaiter.mjs b/src/web/waiters/ResponsiveLayoutWaiter.mjs
new file mode 100644
index 00000000..368b6ea0
--- /dev/null
+++ b/src/web/waiters/ResponsiveLayoutWaiter.mjs
@@ -0,0 +1,283 @@
+/**
+ * Responsive phone / tablet layout for CyberChef.
+ * Uses container width (ResizeObserver) so iframe embeds behave correctly.
+ *
+ * @author blepman
+ * @license Apache-2.0
+ */
+
+const BREAKPOINT_MOBILE = 768;
+const BREAKPOINT_TABLET = 1024;
+
+/**
+ * Manages layout modes: mobile | tablet | desktop.
+ */
+class ResponsiveLayoutWaiter {
+
+ /**
+ * @param {App} app
+ * @param {Manager} manager
+ */
+ constructor(app, manager) {
+ this.app = app;
+ this.manager = manager;
+ this.root = null;
+ this.observer = null;
+ this.currentMode = null;
+ this.mobilePane = "recipe"; // recipe | data
+ this.dataSegment = "input"; // input | output
+ this.opsOpen = false;
+ }
+
+
+ /**
+ * Initialise observer and chrome after DOM is ready.
+ */
+ setup() {
+ this.root = document.getElementById("content-wrapper");
+ if (!this.root) return;
+
+ this.root.classList.add("cyberchef-root");
+ this.root.style.containerType = "inline-size";
+ this.root.style.containerName = "cyberchef";
+
+ this.bindChrome();
+
+ if (typeof ResizeObserver !== "undefined") {
+ this.observer = new ResizeObserver(entries => {
+ const width = entries[0].contentRect.width;
+ this.applyMode(this.modeFromWidth(width));
+ });
+ this.observer.observe(this.root);
+ this.applyMode(this.modeFromWidth(this.root.getBoundingClientRect().width));
+ } else {
+ const onResize = () => {
+ this.applyMode(this.modeFromWidth(window.innerWidth));
+ };
+ window.addEventListener("resize", onResize);
+ onResize();
+ }
+ }
+
+
+ /**
+ * @param {number} width
+ * @returns {"mobile"|"tablet"|"desktop"}
+ */
+ modeFromWidth(width) {
+ if (width < BREAKPOINT_MOBILE) return "mobile";
+ if (width < BREAKPOINT_TABLET) return "tablet";
+ return "desktop";
+ }
+
+
+ /**
+ * Wire mobile/tablet chrome controls.
+ */
+ bindChrome() {
+ const navRecipe = document.getElementById("nav-recipe");
+ const navData = document.getElementById("nav-data");
+ const navOps = document.getElementById("nav-ops-fab");
+ const opsClose = document.getElementById("ops-close");
+ const opsScrim = document.getElementById("ops-scrim");
+ const segInput = document.getElementById("seg-input");
+ const segOutput = document.getElementById("seg-output");
+
+ if (navRecipe) {
+ navRecipe.addEventListener("click", () => this.showMobilePane("recipe"));
+ }
+ if (navData) {
+ navData.addEventListener("click", () => this.showMobilePane("data"));
+ }
+ if (navOps) {
+ navOps.addEventListener("click", () => this.toggleOps(true));
+ }
+ if (opsClose) {
+ opsClose.addEventListener("click", () => this.toggleOps(false));
+ }
+ if (opsScrim) {
+ opsScrim.addEventListener("click", () => this.toggleOps(false));
+ }
+ if (segInput) {
+ segInput.addEventListener("click", () => this.showDataSegment("input"));
+ }
+ if (segOutput) {
+ segOutput.addEventListener("click", () => this.showDataSegment("output"));
+ }
+
+ document.addEventListener("keydown", e => {
+ if (e.key === "Escape" && this.opsOpen) {
+ this.toggleOps(false);
+ }
+ });
+
+ const tabletOps = document.getElementById("tablet-ops-toggle");
+ if (tabletOps) {
+ tabletOps.addEventListener("click", () => this.toggleOps(!this.opsOpen));
+ }
+ }
+
+
+ /**
+ * Switch layout mode and manage Split.js.
+ *
+ * @param {"mobile"|"tablet"|"desktop"} mode
+ */
+ applyMode(mode) {
+ if (mode === this.currentMode || !this.root) return;
+
+ const prev = this.currentMode;
+ this.currentMode = mode;
+ this.root.dataset.layout = mode;
+ document.body.dataset.layout = mode;
+
+ this.placeControls(mode);
+
+ if (mode === "desktop") {
+ this.toggleOps(false);
+ if (prev && prev !== "desktop") {
+ this.app.initialiseSplitter();
+ }
+ } else {
+ this.app.destroySplitters();
+ if (mode === "mobile") {
+ this.showMobilePane(this.mobilePane);
+ this.showDataSegment(this.dataSegment);
+ } else {
+ this.showDataSegment("both");
+ }
+ }
+
+ this.app.adjustComponentSizes();
+ }
+
+
+ /**
+ * On mobile the Bake bar must stay visible across panes, so move #controls
+ * out of #recipe (which is display:none when Data is active).
+ *
+ * @param {"mobile"|"tablet"|"desktop"} mode
+ */
+ placeControls(mode) {
+ const controls = document.getElementById("controls");
+ const recipe = document.getElementById("recipe");
+ const workspace = document.getElementById("workspace-wrapper");
+ if (!controls || !recipe || !workspace) return;
+
+ if (mode === "mobile") {
+ if (controls.parentElement !== workspace) {
+ workspace.appendChild(controls);
+ }
+ controls.classList.add("mobile-controls-bar");
+ } else if (controls.parentElement !== recipe) {
+ recipe.appendChild(controls);
+ controls.classList.remove("mobile-controls-bar");
+ }
+ }
+
+
+ /**
+ * @param {"recipe"|"data"} pane
+ */
+ showMobilePane(pane) {
+ this.mobilePane = pane;
+ if (this.root) {
+ this.root.dataset.mobilePane = pane;
+ }
+
+ const navRecipe = document.getElementById("nav-recipe");
+ const navData = document.getElementById("nav-data");
+ if (navRecipe) {
+ navRecipe.classList.toggle("active", pane === "recipe");
+ navRecipe.setAttribute("aria-selected", pane === "recipe" ? "true" : "false");
+ }
+ if (navData) {
+ navData.classList.toggle("active", pane === "data");
+ navData.setAttribute("aria-selected", pane === "data" ? "true" : "false");
+ }
+
+ this.app.adjustComponentSizes();
+ }
+
+
+ /**
+ * @param {"input"|"output"|"both"} segment
+ */
+ showDataSegment(segment) {
+ this.dataSegment = segment === "both" ? "input" : segment;
+ if (this.root) {
+ this.root.dataset.dataSegment = segment;
+ }
+
+ const segInput = document.getElementById("seg-input");
+ const segOutput = document.getElementById("seg-output");
+ if (segInput && segment !== "both") {
+ segInput.classList.toggle("active", segment === "input");
+ segInput.setAttribute("aria-selected", segment === "input" ? "true" : "false");
+ }
+ if (segOutput && segment !== "both") {
+ segOutput.classList.toggle("active", segment === "output");
+ segOutput.setAttribute("aria-selected", segment === "output" ? "true" : "false");
+ }
+
+ this.app.adjustComponentSizes();
+ }
+
+
+ /**
+ * Open / close operations sheet (mobile) or drawer (tablet).
+ *
+ * @param {boolean} open
+ */
+ toggleOps(open) {
+ this.opsOpen = open;
+ if (this.root) {
+ this.root.classList.toggle("ops-open", open);
+ }
+
+ const overlay = document.getElementById("ops-overlay");
+ if (overlay) {
+ overlay.hidden = !open;
+ overlay.setAttribute("aria-hidden", open ? "false" : "true");
+ }
+
+ const ops = document.getElementById("operations");
+ if (ops) {
+ ops.setAttribute("aria-hidden", open || this.currentMode === "desktop" ? "false" : "true");
+ if (open) {
+ const search = document.getElementById("search");
+ if (search) search.focus();
+ }
+ }
+
+ const fab = document.getElementById("nav-ops-fab");
+ if (fab) {
+ fab.setAttribute("aria-expanded", open ? "true" : "false");
+ }
+
+ const tabletOps = document.getElementById("tablet-ops-toggle");
+ if (tabletOps) {
+ tabletOps.setAttribute("aria-expanded", open ? "true" : "false");
+ }
+ }
+
+
+ /**
+ * After adding an operation from the list, keep sheet open for multi-add.
+ */
+ afterOperationAdded() {
+ if (this.currentMode === "mobile") {
+ this.showMobilePane("recipe");
+ }
+ }
+
+
+ /**
+ * @returns {boolean}
+ */
+ isTouchLayout() {
+ return this.currentMode === "mobile" || this.currentMode === "tablet";
+ }
+}
+
+export default ResponsiveLayoutWaiter;