+ {(() => {
+ // Knob sits on the track at the current angle. 0 rad is at
+ // 3 o'clock, increasing counterclockwise; SVG y points down
+ // so the vertical term is negated.
+ const trackR = 46;
+ const knobX = 60 + trackR * Math.cos(rzAngle);
+ const knobY = 60 - trackR * Math.sin(rzAngle);
+ return (
+
+ );
+ })()}
+
+
+ {/* Edit history: undo/redo, as a second segmented control. */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {props.actionSlot}
+
+ {/*
+ Gate-count breakdown plus a T-count callout. T-count (T and T†
+ gates) is the key cost metric for fault-tolerant implementations,
+ so surfacing it live is useful after the Rz slider expands a
+ rotation into many gates.
+ */}
+
+ {/*
+ Media transport controls: jump-to-start, step-back,
+ play/pause/replay, step-forward, jump-to-end. Step/jump are
+ seek-only; the centre button is the only animated path.
+ */}
+
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/*
+ Speed slider. The value is the speed multiplier (higher =
+ faster); the renderer translates it back to milliseconds.
+ */}
+
+
+
+ {speed.toFixed(2)}×
+
+
+
navigateTo(0)}
+ >
+
+
+ {traceRows}
+
+
+
+
+ );
+}
diff --git a/source/npm/qsharp/ux/bloch/blochGates.ts b/source/npm/qsharp/ux/bloch/blochGates.ts
new file mode 100644
index 0000000000..7cf88b140f
--- /dev/null
+++ b/source/npm/qsharp/ux/bloch/blochGates.ts
@@ -0,0 +1,141 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/*
+ * Pure gate-code metadata and validation for the Bloch-sphere widget.
+ *
+ * Kept separate from bloch.tsx so it can be unit-tested under plain Node
+ * without pulling in three.js, preact, or the JSON data tables.
+ */
+
+import {
+ PauliX,
+ PauliY,
+ PauliZ,
+ SGate,
+ TGate,
+ Hadamard,
+} from "../quantum-math.js";
+
+/** Rotation primitives the renderer exposes (distinct from the x/y/z axis
+ * labels in the visualization). */
+export type RotationAxis = "X" | "Y" | "Z" | "H";
+
+/**
+ * Per-gate metadata, keyed by single-character gate code, shared by the
+ * visualization layer (to animate/snap) and the math layer (to display
+ * the LaTeX equation and update the state vector).
+ */
+export const gateInfo: Record<
+ string,
+ {
+ /** Display name for the LaTeX equation header (e.g. "X", "S\u2020"). */
+ display: string;
+ /** The 2x2 matrix in the computational basis. */
+ matrix: typeof PauliX;
+ /** Pre-rendered LaTeX for the matrix used in the trace pane. */
+ latex: string;
+ /** Which renderer rotation primitive to invoke. */
+ rotateAxis: RotationAxis;
+ /** Angle in radians (sign matters for adjoint variants). */
+ rotateAngle: number;
+ }
+> = {
+ X: {
+ display: "X",
+ matrix: PauliX,
+ latex: "\\begin{bmatrix} 0 & 1 \\\\ 1 & 0 \\end{bmatrix}",
+ rotateAxis: "X",
+ rotateAngle: Math.PI,
+ },
+ Y: {
+ display: "Y",
+ matrix: PauliY,
+ latex: "\\begin{bmatrix} 0 & -i \\\\ i & 0 \\end{bmatrix}",
+ rotateAxis: "Y",
+ rotateAngle: Math.PI,
+ },
+ Z: {
+ display: "Z",
+ matrix: PauliZ,
+ latex: "\\begin{bmatrix} 1 & 0 \\\\ 0 & -1 \\end{bmatrix}",
+ rotateAxis: "Z",
+ rotateAngle: Math.PI,
+ },
+ S: {
+ display: "S",
+ matrix: SGate,
+ latex:
+ "\\begin{bmatrix} 1 & 0 \\\\ 0 & e^{i {\\pi \\over 2}} \\end{bmatrix}",
+ rotateAxis: "Z",
+ rotateAngle: Math.PI / 2,
+ },
+ s: {
+ display: "S\u2020",
+ matrix: SGate.adjoint(),
+ latex:
+ "\\begin{bmatrix} 1 & 0 \\\\ 0 & e^{-i {\\pi \\over 2}} \\end{bmatrix}",
+ rotateAxis: "Z",
+ rotateAngle: -Math.PI / 2,
+ },
+ T: {
+ display: "T",
+ matrix: TGate,
+ latex:
+ "\\begin{bmatrix} 1 & 0 \\\\ 0 & e^{i {\\pi \\over 4}} \\end{bmatrix}",
+ rotateAxis: "Z",
+ rotateAngle: Math.PI / 4,
+ },
+ t: {
+ display: "T\u2020",
+ matrix: TGate.adjoint(),
+ latex:
+ "\\begin{bmatrix} 1 & 0 \\\\ 0 & e^{-i {\\pi \\over 4}} \\end{bmatrix}",
+ rotateAxis: "Z",
+ rotateAngle: -Math.PI / 4,
+ },
+ H: {
+ display: "H",
+ matrix: Hadamard,
+ latex:
+ "{1 \\over \\sqrt{2}} \\begin{bmatrix} 1 & 1 \\\\ 1 & -1 \\end{bmatrix}",
+ rotateAxis: "H",
+ rotateAngle: Math.PI,
+ },
+};
+
+/**
+ * The set of single-character gate codes the widget understands. Each
+ * character here must correspond to a `case` arm in `BlochSphere.rotate`.
+ */
+/** The single-character gate codes the widget understands. */
+export const VALID_GATE_CODES = "XYZHSsTt";
+
+/**
+ * Cap on gates accepted from a single untrusted input (URL parameter,
+ * paste, etc.). Bounds the abuse case (a hostile link with thousands of
+ * gates flooding the animation queue), not the intended UX limit.
+ */
+export const MAX_GATE_SEQUENCE_LENGTH = 256;
+
+const validGateSet = new Set(VALID_GATE_CODES);
+
+/**
+ * Filter a string down to `VALID_GATE_CODES` and cap its length. Returns
+ * the cleaned string and whether anything was dropped.
+ */
+export function sanitizeGateSequence(input: string | undefined | null): {
+ gates: string;
+ modified: boolean;
+} {
+ if (!input) return { gates: "", modified: false };
+ let filtered = "";
+ for (const ch of input) {
+ if (validGateSet.has(ch)) filtered += ch;
+ }
+ const capped = filtered.slice(0, MAX_GATE_SEQUENCE_LENGTH);
+ return {
+ gates: capped,
+ modified: capped.length !== input.length,
+ };
+}
diff --git a/source/npm/qsharp/ux/bloch/blochRenderer.ts b/source/npm/qsharp/ux/bloch/blochRenderer.ts
new file mode 100644
index 0000000000..5480fddc03
--- /dev/null
+++ b/source/npm/qsharp/ux/bloch/blochRenderer.ts
@@ -0,0 +1,713 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// The three.js rendering layer for the Bloch sphere widget. Owns the
+// WebGL scene, the animated/snap rotation logic, and the trail/axis
+// visuals. Kept separate from `bloch.tsx` (the preact component) so the
+// rendering code can be reasoned about without the UI state machine, and
+// vice versa.
+
+import {
+ BoxGeometry,
+ BufferGeometry,
+ CanvasTexture,
+ ConeGeometry,
+ CylinderGeometry,
+ DirectionalLight,
+ Group,
+ Line,
+ LineBasicMaterial,
+ Mesh,
+ MeshBasicMaterial,
+ MeshLambertMaterial,
+ PerspectiveCamera,
+ Scene,
+ SphereGeometry,
+ Sprite,
+ SpriteMaterial,
+ Vector3,
+ WebGLRenderer,
+} from "three";
+
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
+
+import { AppliedGate, Rotations } from "../cplx.js";
+import { RotationAxis } from "./blochGates.js";
+
+// Two color palettes for the directly-WebGL-drawn parts of the scene
+// (sphere material, label sprites), picked to stay legible on light/dark
+// backgrounds. CSS-styled parts use the shared QDK theme tokens instead.
+const lightThemeColors = {
+ sphereColor: 0x404080,
+ sphereBrightness: 2,
+ sphereOpacity: 0.5,
+ directionalLightBrightness: 0.25,
+ markerColor: 0xc00000,
+ sphereLinesOpacity: 0.2,
+ labelCanvasColor: "#606080",
+};
+
+const darkThemeColors = {
+ sphereColor: 0x8080c0,
+ sphereBrightness: 1.6,
+ sphereOpacity: 0.55,
+ directionalLightBrightness: 0.35,
+ markerColor: 0xff5050,
+ sphereLinesOpacity: 0.35,
+ labelCanvasColor: "#d0d0e0",
+};
+
+function colorsFor(isDark: boolean) {
+ return isDark ? darkThemeColors : lightThemeColors;
+}
+
+// See https://gizma.com/easing/#easeInOutSine
+function easeInOutSine(x: number) {
+ return -(Math.cos(Math.PI * x) - 1) / 2;
+}
+
+function easeOutSine(x: number) {
+ return Math.sin((x * Math.PI) / 2);
+}
+
+function hslToRgb(h: number, s: number, l: number) {
+ let r, g, b;
+
+ if (s === 0) {
+ r = g = b = l; // achromatic
+ } else {
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+ r = hueToRgb(p, q, h + 1 / 3);
+ g = hueToRgb(p, q, h);
+ b = hueToRgb(p, q, h - 1 / 3);
+ }
+ return (
+ (Math.min(r * 255, 255) << 16) |
+ (Math.min(g * 255, 255) << 8) |
+ Math.min(b * 255, 255)
+ );
+}
+
+function hueToRgb(p: number, q: number, t: number) {
+ if (t < 0) t += 1;
+ if (t > 1) t -= 1;
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
+ if (t < 1 / 2) return q;
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
+ return p;
+}
+
+function makeLabelSprite(text: string, fillStyle: string): Sprite {
+ // Render the label into an offscreen canvas used as a sprite texture.
+ // Sprites always face the camera, so labels stay legible while orbiting.
+ const size = 128;
+ const canvas = document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext("2d")!;
+ ctx.font = "bold 96px sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillStyle = fillStyle;
+ ctx.fillText(text, size / 2, size / 2);
+
+ const texture = new CanvasTexture(canvas);
+ // depthWrite off: the mostly-transparent quad would otherwise punch a
+ // box-shaped hole in the grid circles behind it.
+ const material = new SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false,
+ });
+ const sprite = new Sprite(material);
+ sprite.scale.set(1.2, 1.2, 1);
+ return sprite;
+}
+
+function createLabels(isDark: boolean): Sprite[] {
+ const fill = colorsFor(isDark).labelCanvasColor;
+
+ const xLabel = makeLabelSprite("x", fill);
+ xLabel.position.set(0, 0, 6.4);
+
+ const yLabel = makeLabelSprite("y", fill);
+ yLabel.position.set(6.4, 0, 0);
+
+ const zLabel = makeLabelSprite("z", fill);
+ zLabel.position.set(0, 6.4, 0);
+
+ return [xLabel, yLabel, zLabel];
+}
+
+// Default duration of a single gate animation (ms). The speed slider
+// overwrites `BlochRenderer.rotationTimeMs` directly; the rAF loop
+// re-reads it every frame so changes take effect mid-animation. The
+// slider's 0.25x..4x range covers ~1.3s down to ~83ms per gate.
+export const DEFAULT_ROTATION_TIME_MS = 333;
+
+export class BlochRenderer {
+ scene: Scene;
+ camera: PerspectiveCamera;
+ renderer: WebGLRenderer;
+ controls: OrbitControls;
+ qubit: Group;
+ trail: Group;
+ rotationAxis: Group;
+ animationCallbackId = 0;
+ // Public so the speed slider can mutate it directly; no derived state.
+ rotationTimeMs = DEFAULT_ROTATION_TIME_MS;
+ // Animation queue. Each entry's optional `onComplete` fires when that
+ // gate's animation ends; the play loop uses it to chain to the next gate.
+ gateQueue: { gate: AppliedGate; onComplete?: () => void }[] = [];
+ rotations: Rotations;
+ // Stored so setTheme() can recolor materials and swap label sprites.
+ sphereMaterial: MeshLambertMaterial;
+ sphereLineMaterial: LineBasicMaterial;
+ markerMaterial: MeshBasicMaterial;
+ directionalLight: DirectionalLight;
+ labelSprites: Sprite[] = [];
+ isDark: boolean;
+ // Shared GPU resources for trail dots. Without these, every replayed gate
+ // and animation frame allocated a fresh geometry + material per path
+ // point, which froze the UI on trace-row clicks for long sequences.
+ private trailDotGeometry!: SphereGeometry;
+ // Pre-built fade-color palette; dot age maps to an index via
+ // `getTrailDotMaterial`, replacing per-dot material allocation.
+ private trailDotMaterials!: MeshBasicMaterial[];
+
+ constructor(canvas: HTMLCanvasElement, isDark: boolean) {
+ this.rotations = new Rotations(64);
+ this.isDark = isDark;
+
+ // Build the shared trail-dot resources up front so snapTo/queueGate
+ // hot paths just link objects rather than allocate.
+ this.trailDotGeometry = new SphereGeometry(0.05, 16, 16);
+ const TRAIL_PALETTE_SIZE = 32;
+ this.trailDotMaterials = [];
+ for (let i = 0; i < TRAIL_PALETTE_SIZE; i++) {
+ const sat = i / (TRAIL_PALETTE_SIZE - 1);
+ this.trailDotMaterials.push(
+ new MeshBasicMaterial({ color: hslToRgb(0.6, sat, 0.5) }),
+ );
+ }
+ const palette = colorsFor(isDark);
+
+ const renderer = new WebGLRenderer({
+ canvas,
+ antialias: true,
+ alpha: true,
+ });
+
+ const scene = new Scene();
+ const camera = new PerspectiveCamera(
+ 30, // fov
+ 1, // aspect
+ 0.1, // near
+ 1000, // far
+ );
+
+ // In WebGL, Z is towards the camera (viewer looking towards -Z), Y is up, X is right
+ // Position slightly towards the X and Y axis to give a 'canonical' view
+ camera.position.x = 4;
+ camera.position.y = 4;
+ camera.position.z = 27;
+ camera.lookAt(0, 0, 0);
+
+ const light = new DirectionalLight(
+ 0xffffff,
+ palette.directionalLightBrightness,
+ );
+ light.position.set(-1, 2, 4);
+ scene.add(light);
+ this.directionalLight = light;
+
+ // Note that the orbit controls move the camera, they don't rotate the
+ // scene, so the X, Y, and Z axis for the Bloch sphere remain fixed.
+ const controls = new OrbitControls(camera, renderer.domElement);
+
+ // Create a group to hold the qubit
+ const qubit = new Group();
+
+ // The sphere and its grid lines stay fixed: a gate rotates the qubit's
+ // *state*, not the reference frame. Only the position marker lives in
+ // the rotating `qubit` group.
+ const sphereFrame = new Group();
+
+ // Add the main sphere.
+ const sphereGeometry = new SphereGeometry(5, 96, 64);
+ const material = new MeshLambertMaterial({
+ emissive: palette.sphereColor,
+ emissiveIntensity: palette.sphereBrightness,
+ transparent: true,
+ opacity: palette.sphereOpacity,
+ });
+ this.sphereMaterial = material;
+ const sphere = new Mesh(sphereGeometry, material);
+ // Draw the sphere (renderOrder 0) before the grid lines (1). Both are
+ // transparent, so without an explicit order three.js sorts by centroid
+ // and the sphere sometimes paints over the near-side lines.
+ sphere.renderOrder = 0;
+ sphereFrame.add(sphere);
+
+ // The spin-direction marker is the only part of the qubit group that
+ // moves with a gate; it tracks the state vector across the sphere.
+ const coneGeometry = new ConeGeometry(0.2, 0.75, 32);
+ const coneMat = new MeshBasicMaterial({ color: palette.markerColor });
+ this.markerMaterial = coneMat;
+ const marker = new Mesh(coneGeometry, coneMat);
+ marker.position.set(0, 5.125, 0.4);
+ marker.rotateX(Math.PI / 2);
+ qubit.add(marker);
+
+ // Draw smooth latitude/longitude grid lines on the sphere. Each circle
+ // is a single high-segment line loop, which reads as a clean great-circle.
+ const gridRadius = 5.1;
+ const circleSegments = 128;
+ const lineMaterial = new LineBasicMaterial({
+ // Test depth so far-side circles stay occluded, but don't write it
+ // (lines render after the sphere and shouldn't depth-fight).
+ depthTest: true,
+ depthWrite: false,
+ transparent: true,
+ opacity: palette.sphereLinesOpacity,
+ });
+ this.sphereLineMaterial = lineMaterial;
+ const sphereLines = new Group();
+
+ const addCircle = (pointAt: (angle: number) => Vector3) => {
+ const points: Vector3[] = [];
+ for (let i = 0; i <= circleSegments; i++) {
+ points.push(pointAt((i / circleSegments) * Math.PI * 2));
+ }
+ const geometry = new BufferGeometry().setFromPoints(points);
+ const line = new Line(geometry, lineMaterial);
+ // After the sphere so it never paints over the lines.
+ line.renderOrder = 1;
+ sphereLines.add(line);
+ };
+
+ // Latitude circles, skipping the degenerate pole rings.
+ const latitudeCount = 18;
+ for (let i = 1; i < latitudeCount; i++) {
+ const theta = (i / latitudeCount) * Math.PI;
+ const y = gridRadius * Math.cos(theta);
+ const r = gridRadius * Math.sin(theta);
+ addCircle(
+ (angle) => new Vector3(r * Math.cos(angle), y, r * Math.sin(angle)),
+ );
+ }
+
+ // Longitude circles: great circles through both poles. Each
+ // half-meridian repeats on the far side, so half a turn covers all.
+ const longitudeCount = 18;
+ for (let i = 0; i < longitudeCount; i++) {
+ const phi = (i / longitudeCount) * Math.PI;
+ const cosPhi = Math.cos(phi);
+ const sinPhi = Math.sin(phi);
+ addCircle(
+ (angle) =>
+ new Vector3(
+ gridRadius * Math.sin(angle) * cosPhi,
+ gridRadius * Math.cos(angle),
+ gridRadius * Math.sin(angle) * sinPhi,
+ ),
+ );
+ }
+
+ sphereFrame.add(sphereLines);
+ scene.add(sphereFrame);
+ scene.add(qubit);
+
+ // Create a group to hold the trailing points
+ const trail = new Group();
+ scene.add(trail);
+
+ // Add the axes
+ const axisMaterial = new MeshBasicMaterial({ color: 0xe0d0c0 });
+ const zAxis = new CylinderGeometry(0.075, 0.075, 12, 32, 8);
+ const zAxisMesh = new Mesh(zAxis, axisMaterial);
+ scene.add(zAxisMesh);
+
+ const zPointer = new ConeGeometry(0.2, 0.8, 16);
+ const zPointerMesh = new Mesh(zPointer, axisMaterial);
+ zPointerMesh.position.set(0, 6, 0);
+ scene.add(zPointerMesh);
+
+ const yAxisMesh = new Mesh(zAxis, axisMaterial);
+ yAxisMesh.rotateZ(Math.PI / 2);
+ scene.add(yAxisMesh);
+ const yPointerMesh = new Mesh(zPointer, axisMaterial);
+ yPointerMesh.position.set(6, 0, 0);
+ yPointerMesh.rotateZ(-Math.PI / 2);
+ scene.add(yPointerMesh);
+
+ const xAxisMesh = new Mesh(zAxis, axisMaterial);
+ xAxisMesh.rotateX(Math.PI / 2);
+ scene.add(xAxisMesh);
+ const xPointerMesh = new Mesh(zPointer, axisMaterial);
+ xPointerMesh.position.set(0, 0, 6);
+ xPointerMesh.rotateX(Math.PI / 2);
+ scene.add(xPointerMesh);
+
+ const rotationAxis = new Group();
+ const rotationAxisMaterial = new MeshLambertMaterial({
+ emissive: 0x808080,
+ emissiveIntensity: 1.5,
+ transparent: true,
+ opacity: 0.75,
+ });
+ const axisBox = new BoxGeometry(0.33, 0.33, 12.5);
+ const axisBoxMesh = new Mesh(axisBox, rotationAxisMaterial);
+ rotationAxis.add(axisBoxMesh);
+
+ const fins = [
+ [2, 0.25, 0.25, 0, 0, 5.75],
+ [0.25, 2, 0.25, 0, 0, 5.75],
+ [2, 0.25, 0.25, 0, 0, -5.75],
+ [0.25, 0.25, 2, 0, 0, -5.75],
+ ];
+
+ fins.forEach((fin) => {
+ const finBox = new BoxGeometry(fin[0], fin[1], fin[2]);
+ const finBoxMesh = new Mesh(finBox, rotationAxisMaterial);
+ finBoxMesh.position.set(fin[3], fin[4], fin[5]);
+ rotationAxis.add(finBoxMesh);
+ });
+
+ this.rotationAxis = rotationAxis;
+
+ // See https://threejs.org/manual/#en/rendering-on-demand
+ controls.addEventListener("change", () =>
+ requestAnimationFrame(() => this.render()),
+ );
+
+ this.renderer = renderer;
+ this.scene = scene;
+ this.camera = camera;
+ this.controls = controls;
+ this.qubit = qubit;
+ this.trail = trail;
+
+ // Labels are synchronous now, so just create them and render once.
+ this.labelSprites = createLabels(isDark);
+ this.labelSprites.forEach((s) => scene.add(s));
+ this.render();
+ }
+
+ queueGate(gate: AppliedGate, onComplete?: () => void) {
+ this.gateQueue.push({ gate, onComplete });
+ if (this.animationCallbackId) return; // Queue is already running
+
+ // Close over these values for the running queue
+ let currentEntry:
+ | { gate: AppliedGate; onComplete?: () => void }
+ | undefined;
+ let startTime = 0;
+
+ const processQueue = () => {
+ if (!currentEntry) {
+ currentEntry = this.gateQueue.shift();
+ if (!currentEntry) {
+ // Queue was empty. Done
+ this.animationCallbackId = 0;
+ return;
+ } else {
+ const axisInLocal = this.qubit.worldToLocal(currentEntry.gate.axis);
+ this.rotationAxis.lookAt(axisInLocal);
+ this.qubit.add(this.rotationAxis);
+ startTime = performance.now();
+ }
+ }
+
+ // Calculate the percent of rotation time elapsed from start to now
+ const x = (performance.now() - startTime) / this.rotationTimeMs;
+
+ // Ease the rotation
+ const t = x < 1 ? easeInOutSine(x) : 1;
+
+ // Rotate the qubit to the correct position
+ const currentRotation = this.rotations.getRotationAtPercent(
+ currentEntry.gate,
+ t,
+ );
+
+ currentRotation.path.forEach((val) => {
+ // Draw any that don't already have a point
+ if (val.ref) return;
+ // Shared geometry + placeholder material; the fade pass assigns the
+ // correct material this frame.
+ const trackBall = new Mesh(
+ this.trailDotGeometry,
+ this.trailDotMaterials[0],
+ );
+ trackBall.position.set(0, 5, 0);
+
+ // Convert to world space
+ trackBall.position.applyQuaternion(val.pos);
+
+ // Save along with the interpolation point
+ this.trail.add(trackBall);
+ val.ref = trackBall;
+ });
+
+ // Set qubit position to slerped values
+ this.qubit.quaternion.copy(currentRotation.pos);
+
+ // Fade out the trail using shared palette materials.
+ this.trail.children.forEach((child, idx, arr) => {
+ const ball = child as Mesh;
+ const sat = easeOutSine((idx + 1) / arr.length);
+ ball.material = this.getTrailDotMaterial(sat);
+ ball.scale.setScalar(sat + 0.5);
+ });
+
+ this.render();
+
+ // Gate done: fire the completion callback (which may queue another
+ // gate -- queueGate sees the live animationCallbackId and appends).
+ if (t >= 1) {
+ const finishedCb = currentEntry.onComplete;
+ currentEntry = undefined;
+ this.qubit.remove(this.rotationAxis);
+ this.render();
+ finishedCb?.();
+ }
+
+ this.animationCallbackId = requestAnimationFrame(processQueue);
+ };
+
+ // Kick off processing
+ processQueue();
+ }
+
+ /**
+ * Animate a single gate by axis + angle, optionally invoking
+ * `onComplete` when it finishes. The seam the component's play loop uses
+ * to chain gates without knowing about `AppliedGate` / `Rotations`.
+ */
+ animateStep(axis: RotationAxis, angle: number, onComplete?: () => void) {
+ let applied: AppliedGate;
+ switch (axis) {
+ case "X":
+ applied = this.rotations.rotateX(angle);
+ break;
+ case "Y":
+ applied = this.rotations.rotateY(angle);
+ break;
+ case "Z":
+ applied = this.rotations.rotateZ(angle);
+ break;
+ case "H":
+ applied = this.rotations.rotateH(angle);
+ break;
+ }
+ this.queueGate(applied, onComplete);
+ }
+
+ rotateX(angle: number) {
+ this.queueGate(this.rotations.rotateX(angle));
+ }
+
+ rotateY(angle: number) {
+ this.queueGate(this.rotations.rotateY(angle));
+ }
+
+ rotateZ(angle: number) {
+ this.queueGate(this.rotations.rotateZ(angle));
+ }
+
+ rotateH(angle: number) {
+ this.queueGate(this.rotations.rotateH(angle));
+ }
+
+ reset() {
+ // Cancel any in-flight animation and drain the queue so its render
+ // callback can't write the in-progress quaternion back over the reset
+ // (otherwise clearing mid-playback leaves the state indicator stranded
+ // wherever the last frame landed).
+ if (this.animationCallbackId) {
+ cancelAnimationFrame(this.animationCallbackId);
+ this.animationCallbackId = 0;
+ }
+ this.gateQueue.length = 0;
+ // Detach the rotation-axis indicator in case we're cancelling mid-flight.
+ this.qubit.remove(this.rotationAxis);
+ this.controls.reset();
+ this.rotations.reset();
+ this.trail.clear();
+ this.scene.position.set(0, 0, 0);
+ this.qubit.quaternion.identity();
+ this.qubit.rotation.set(0, 0, 0);
+ this.camera.position.set(4, 4, 27);
+ this.camera.lookAt(0, 0, 0);
+ this.render();
+ }
+
+ /**
+ * Apply a sequence of rotations instantly with no animation, rebuilding
+ * the dotted trail from the same interpolation points the animated path
+ * uses. Used by trace inspection and undo/redo.
+ */
+ snapTo(steps: { axis: RotationAxis; angle: number }[]) {
+ // Cancel any in-flight animation so its render callback doesn't write
+ // the in-progress quaternion back over our snap.
+ if (this.animationCallbackId) {
+ cancelAnimationFrame(this.animationCallbackId);
+ this.animationCallbackId = 0;
+ }
+ this.gateQueue.length = 0;
+ this.trail.clear();
+ // Detach the rotation-axis indicator in case we're cancelling mid-flight.
+ this.qubit.remove(this.rotationAxis);
+
+ // Reset the rotation model, then apply each step, keeping each
+ // AppliedGate so we can rebuild the trail from its interpolation path.
+ this.rotations.reset();
+ this.qubit.quaternion.identity();
+ for (const { axis, angle } of steps) {
+ let applied;
+ switch (axis) {
+ case "X":
+ applied = this.rotations.rotateX(angle);
+ break;
+ case "Y":
+ applied = this.rotations.rotateY(angle);
+ break;
+ case "Z":
+ applied = this.rotations.rotateZ(angle);
+ break;
+ case "H":
+ applied = this.rotations.rotateH(angle);
+ break;
+ }
+ // Same trackball construction as queueGate, but defer color/scale to
+ // one fade pass after the loop. These dots are throwaway visuals
+ // owned by the snap, so we don't set val.ref.
+ for (const val of applied.path) {
+ const trackBall = new Mesh(
+ this.trailDotGeometry,
+ this.trailDotMaterials[0],
+ );
+ trackBall.position.set(0, 5, 0);
+ trackBall.position.applyQuaternion(val.pos);
+ this.trail.add(trackBall);
+ }
+ }
+ // Same age-based fade as the animation loop, so a rebuilt trail looks
+ // identical to one drawn step by step.
+ this.trail.children.forEach((child, idx, arr) => {
+ const ball = child as Mesh;
+ const sat = easeOutSine((idx + 1) / arr.length);
+ ball.material = this.getTrailDotMaterial(sat);
+ ball.scale.setScalar(sat + 0.5);
+ });
+ this.qubit.quaternion.copy(this.rotations.currPosition);
+ this.render();
+ }
+
+ /**
+ * Map an age-fade saturation in [0, 1] to a pre-built palette material,
+ * avoiding per-dot material allocation. Bucketing into 32 entries is
+ * visually imperceptible.
+ */
+ private getTrailDotMaterial(sat: number): MeshBasicMaterial {
+ const n = this.trailDotMaterials.length;
+ const idx = Math.min(n - 1, Math.max(0, Math.floor(sat * n)));
+ return this.trailDotMaterials[idx];
+ }
+
+ render() {
+ this.controls.update();
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ // Resize the WebGL buffer to the container's on-screen size. The `false`
+ // arg leaves the canvas CSS size alone so it keeps filling its flex cell
+ // while render resolution tracks actual pixels; aspect is updated to
+ // keep the sphere round.
+ resize(width: number, height: number) {
+ const w = Math.max(1, Math.floor(width));
+ const h = Math.max(1, Math.floor(height));
+ this.renderer.setPixelRatio(window.devicePixelRatio || 1);
+ this.renderer.setSize(w, h, false);
+ this.camera.aspect = w / h;
+ this.camera.updateProjectionMatrix();
+ this.render();
+ }
+
+ setTheme(isDark: boolean) {
+ if (this.isDark === isDark) return;
+ this.isDark = isDark;
+ const palette = colorsFor(isDark);
+
+ this.sphereMaterial.emissive.setHex(palette.sphereColor);
+ this.sphereMaterial.emissiveIntensity = palette.sphereBrightness;
+ this.sphereMaterial.opacity = palette.sphereOpacity;
+ this.sphereMaterial.needsUpdate = true;
+
+ this.markerMaterial.color.setHex(palette.markerColor);
+ this.markerMaterial.needsUpdate = true;
+
+ this.sphereLineMaterial.opacity = palette.sphereLinesOpacity;
+ this.sphereLineMaterial.needsUpdate = true;
+
+ this.directionalLight.intensity = palette.directionalLightBrightness;
+
+ // Sprite textures are baked at their generation colors, so recreate them.
+ this.labelSprites.forEach((sprite) => {
+ this.scene.remove(sprite);
+ sprite.material.map?.dispose();
+ sprite.material.dispose();
+ });
+ this.labelSprites = createLabels(isDark);
+ this.labelSprites.forEach((s) => this.scene.add(s));
+
+ this.render();
+ }
+
+ dispose() {
+ // Stop any in-flight frame so it doesn't render into a dead context.
+ if (this.animationCallbackId) {
+ cancelAnimationFrame(this.animationCallbackId);
+ this.animationCallbackId = 0;
+ }
+ this.controls.dispose();
+ // three.js doesn't free GPU resources automatically; walk the scene and
+ // dispose each Mesh's geometry/material/textures, or remounts leak GPU
+ // memory and WebGL contexts. Trail dots share resources, disposed once
+ // below, so skip them here.
+ const sharedGeo = this.trailDotGeometry;
+ const sharedMats = new Set(this.trailDotMaterials);
+ this.scene.traverse((obj) => {
+ const mesh = obj as Mesh;
+ if (mesh.geometry && mesh.geometry !== sharedGeo) {
+ mesh.geometry.dispose();
+ }
+ const mat = mesh.material as
+ | { map?: { dispose: () => void }; dispose?: () => void }
+ | { map?: { dispose: () => void }; dispose?: () => void }[]
+ | undefined;
+ if (Array.isArray(mat)) {
+ mat.forEach((m) => {
+ if (sharedMats.has(m as MeshBasicMaterial)) return;
+ m.map?.dispose();
+ m.dispose?.();
+ });
+ } else if (mat && !sharedMats.has(mat as MeshBasicMaterial)) {
+ mat.map?.dispose();
+ mat.dispose?.();
+ }
+ });
+ // Dispose the shared trail-dot resources exactly once.
+ sharedGeo.dispose();
+ this.trailDotMaterials.forEach((m) => m.dispose());
+ this.trailDotMaterials = [];
+ this.labelSprites.forEach((sprite) => {
+ sprite.material.map?.dispose();
+ sprite.material.dispose();
+ });
+ this.labelSprites = [];
+ this.renderer.dispose();
+ }
+}
diff --git a/source/npm/qsharp/ux/circuit-vis/state-viz/worker/stateCompute.ts b/source/npm/qsharp/ux/circuit-vis/state-viz/worker/stateCompute.ts
index f148b578b7..80ce087e08 100644
--- a/source/npm/qsharp/ux/circuit-vis/state-viz/worker/stateCompute.ts
+++ b/source/npm/qsharp/ux/circuit-vis/state-viz/worker/stateCompute.ts
@@ -5,9 +5,30 @@
// Implements a small statevector simulator that evaluates the circuit model and
// produces an amplitude map. Intentionally avoids DOM/visualization concerns so
// it can run on the main thread or in a Web Worker.
+//
+// The complex-number, 2x2 matrix, and gate-matrix definitions are shared with
+// the Bloch sphere widget via `../../../quantum-math.js`. That module is
+// deliberately three.js-free so it can be bundled into this worker without
+// pulling in three.js's ~600 KB. Do NOT switch this import to `cplx.js` --
+// that file additionally re-exports the quaternion-driven Rotations engine and
+// would drag three into the worker.
import type { ComponentGrid, Operation, Qubit } from "../../circuit.js";
import { evaluateAngleExpression } from "../../angleExpression.js";
+import {
+ Cplx,
+ M2x2,
+ PauliX,
+ PauliY,
+ PauliZ,
+ Hadamard,
+ SGate,
+ TGate,
+ SXGate,
+ rotationX,
+ rotationY,
+ rotationZ,
+} from "../../../quantum-math.js";
// This holds the complex amplitudes of the different basis states.
export type AmpMap = Record;
@@ -19,107 +40,6 @@ export class UnsupportedStateComputeError extends Error {
}
}
-// Small complex helpers
-class Complex {
- constructor(
- public re: number,
- public im: number,
- ) {}
- static add(a: Complex, b: Complex) {
- return new Complex(a.re + b.re, a.im + b.im);
- }
- static mul(a: Complex, b: Complex) {
- return new Complex(a.re * b.re - a.im * b.im, a.re * b.im + a.im * b.re);
- }
- static conj(a: Complex) {
- return new Complex(a.re, -a.im);
- }
-}
-
-function adjointMat2(mat: Complex[]): Complex[] {
- // 2x2 matrix stored as [m00, m01, m10, m11].
- // Adjoint is conjugate transpose: [[conj(m00), conj(m10)], [conj(m01), conj(m11)]].
- return [
- Complex.conj(mat[0]),
- Complex.conj(mat[2]),
- Complex.conj(mat[1]),
- Complex.conj(mat[3]),
- ];
-}
-
-// Matrices for single-qubit gates
-const GATE = {
- X: [
- new Complex(0, 0),
- new Complex(1, 0),
- new Complex(1, 0),
- new Complex(0, 0),
- ],
- Y: [
- new Complex(0, 0),
- new Complex(0, -1),
- new Complex(0, 1),
- new Complex(0, 0),
- ],
- Z: [
- new Complex(1, 0),
- new Complex(0, 0),
- new Complex(0, 0),
- new Complex(-1, 0),
- ],
- H: [
- new Complex(Math.SQRT1_2, 0),
- new Complex(Math.SQRT1_2, 0),
- new Complex(Math.SQRT1_2, 0),
- new Complex(-Math.SQRT1_2, 0),
- ],
- S: [
- new Complex(1, 0),
- new Complex(0, 0),
- new Complex(0, 0),
- new Complex(0, 1),
- ], // [[1,0],[0,i]]
- T: [
- new Complex(1, 0),
- new Complex(0, 0),
- new Complex(0, 0),
- new Complex(Math.SQRT1_2, Math.SQRT1_2),
- ],
- SX: [
- // sqrt(X)
- new Complex(0.5, 0.5),
- new Complex(0.5, -0.5),
- new Complex(0.5, -0.5),
- new Complex(0.5, 0.5),
- ],
-};
-
-function rotationX(theta: number) {
- const c = Math.cos(theta / 2);
- const s = Math.sin(theta / 2);
- return [
- new Complex(c, 0),
- new Complex(0, -s),
- new Complex(0, -s),
- new Complex(c, 0),
- ];
-}
-function rotationY(theta: number) {
- const c = Math.cos(theta / 2);
- const s = Math.sin(theta / 2);
- return [
- new Complex(c, 0),
- new Complex(-s, 0),
- new Complex(s, 0),
- new Complex(c, 0),
- ];
-}
-function rotationZ(theta: number) {
- const eNeg = new Complex(Math.cos(-theta / 2), Math.sin(-theta / 2));
- const ePos = new Complex(Math.cos(theta / 2), Math.sin(theta / 2));
- return [eNeg, new Complex(0, 0), new Complex(0, 0), ePos];
-}
-
function parseTheta(op: Operation): number | undefined {
const arg = op.args?.[0];
if (!arg) return undefined;
@@ -128,13 +48,19 @@ function parseTheta(op: Operation): number | undefined {
}
function applySingleQubit(
- state: Complex[],
+ state: Cplx[],
target: number,
- mat: Complex[],
+ mat: M2x2,
controls: number[] = [],
): void {
const N = state.length;
const mask = 1 << target;
+ // Hoist the 4 matrix entries to locals so the hot inner loop is pure
+ // multiply/add on already-resolved Cplx instances.
+ const m00 = mat.a;
+ const m01 = mat.b;
+ const m10 = mat.c;
+ const m11 = mat.d;
for (let i = 0; i < N; i += 2 * mask) {
for (let j = 0; j < mask; j++) {
const i0 = i + j;
@@ -143,10 +69,8 @@ function applySingleQubit(
if (!okControls) continue;
const a0 = state[i0];
const a1 = state[i1];
- const n0 = Complex.add(Complex.mul(mat[0], a0), Complex.mul(mat[1], a1));
- const n1 = Complex.add(Complex.mul(mat[2], a0), Complex.mul(mat[3], a1));
- state[i0] = n0;
- state[i1] = n1;
+ state[i0] = m00.mul(a0).add(m01.mul(a1));
+ state[i1] = m10.mul(a0).add(m11.mul(a1));
}
}
}
@@ -158,9 +82,9 @@ export function computeAmpMapForCircuit(
const n = qubits.length;
if (n === 0) return {};
const dim = 1 << n;
- const state: Complex[] = new Array(dim);
- for (let i = 0; i < dim; i++) state[i] = new Complex(0, 0);
- state[0] = new Complex(1, 0);
+ const state: Cplx[] = new Array(dim);
+ for (let i = 0; i < dim; i++) state[i] = Cplx.zero;
+ state[0] = Cplx.one;
for (const col of componentGrid) {
for (const op of col.components) {
@@ -174,28 +98,28 @@ export function computeAmpMapForCircuit(
continue;
}
const t = targetQubits[0];
- let mat: Complex[] | undefined;
+ let mat: M2x2 | undefined;
switch (op.gate) {
case "X":
- mat = GATE.X;
+ mat = PauliX;
break;
case "Y":
- mat = GATE.Y;
+ mat = PauliY;
break;
case "Z":
- mat = GATE.Z;
+ mat = PauliZ;
break;
case "H":
- mat = GATE.H;
+ mat = Hadamard;
break;
case "S":
- mat = GATE.S;
+ mat = SGate;
break;
case "T":
- mat = GATE.T;
+ mat = TGate;
break;
case "SX":
- mat = GATE.SX;
+ mat = SXGate;
break;
case "Rx": {
const th = parseTheta(op);
@@ -216,7 +140,7 @@ export function computeAmpMapForCircuit(
break;
}
if (mat) {
- mat = isAdjoint ? adjointMat2(mat) : mat;
+ mat = isAdjoint ? mat.adjoint() : mat;
applySingleQubit(state, t, mat, controls);
}
break;
diff --git a/source/npm/qsharp/ux/cplx.ts b/source/npm/qsharp/ux/cplx.ts
new file mode 100644
index 0000000000..ef54db977d
--- /dev/null
+++ b/source/npm/qsharp/ux/cplx.ts
@@ -0,0 +1,144 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// Three.js-aware extension of `./quantum-math.js`. The complex-number,
+// 2-vector, 2x2-matrix, and gate-matrix primitives live in
+// `quantum-math.ts` (worker-safe, no `three` import); this file adds the
+// quaternion-driven `Rotations` engine that powers the Bloch sphere
+// animation. We re-export everything from `quantum-math` so existing
+// consumers (notably `bloch.tsx` and `tools/rz-synthesis.ts`) keep
+// working without changing their import paths.
+
+import { Quaternion, Vector3 } from "three";
+import { compare, numToStr } from "./quantum-math.js";
+
+export * from "./quantum-math.js";
+
+// Holds a set of rotations for a qubit, and the points in that rotation
+export type AppliedGate = {
+ name: string;
+ axis: Vector3;
+ angle: number;
+ path: { pos: Quaternion; ref?: any }[];
+ endPos: Quaternion;
+};
+
+export type PathEntry = { pos: Quaternion; ref?: any };
+
+export class Rotations {
+ gates: AppliedGate[] = [];
+ currPosition = new Quaternion();
+
+ constructor(
+ public pointsPerRotation = 32, // Assuming a common gate rotation of pi radians
+ public timePerGateMs = 500,
+ ) {}
+
+ reset() {
+ this.gates = [];
+ this.currPosition = new Quaternion();
+ }
+
+ getPathLength(axis: Vector3, rotationAngle: number): number {
+ /*
+ To calculate the distance a point travels around a unit sphere as a rotation is applied.
+ - Calculate the angle (theta) between the axis of rotation and the point
+ - Get the radius for the circle around the (unit) sphere at theta
+ - Calculate the distance traveled as the rotation angle * radius
+ */
+
+ const pointStart = new Vector3(0, 1, 0);
+ const pointCurrent = pointStart.applyQuaternion(this.currPosition);
+ const pointToAxisAngle = pointCurrent.angleTo(axis);
+ const arcRadius = Math.sin(pointToAxisAngle);
+ const pathTraveled = arcRadius * rotationAngle;
+ return Math.abs(pathTraveled);
+ }
+
+ applyGate(name: string, axis: Vector3, angle: number): AppliedGate {
+ // Get the target position by applying the rotation to the current position
+ const endPos = new Quaternion()
+ .setFromAxisAngle(axis, angle)
+ .multiply(this.currPosition);
+
+ const pathDistance = this.getPathLength(axis, angle);
+ const pointCount = Math.floor(
+ (pathDistance * this.pointsPerRotation) / Math.PI,
+ );
+
+ // Generate a set of points between the current and target position
+ const path: PathEntry[] = [];
+ for (let i = 0; i < pointCount; i++) {
+ const t = i / pointCount;
+ path.push({ pos: this.currPosition.clone().slerp(endPos, t) });
+ }
+ const gate = { name, path, endPos, axis, angle };
+ this.gates.push(gate);
+
+ // Update the current position to the final target
+ this.currPosition = endPos;
+ return gate;
+ }
+
+ rotateX(angle?: number): AppliedGate {
+ const name = angle === undefined ? "X" : `X(${numToStr(angle)})`;
+ if (angle === undefined) angle = Math.PI;
+ // The Bloch sphere X axis is the Z axis in WebGL
+ return this.applyGate(name, new Vector3(0, 0, 1), angle);
+ }
+ rotateY(angle?: number): AppliedGate {
+ const name = angle === undefined ? "Y" : `Y(${numToStr(angle)})`;
+ if (angle === undefined) angle = Math.PI;
+ // The Bloch sphere Y axis is the X axis in WebGL
+ return this.applyGate(name, new Vector3(1, 0, 0), angle);
+ }
+
+ rotateZ(angle?: number): AppliedGate {
+ const name =
+ angle === undefined
+ ? "Z"
+ : compare(angle, Math.PI / 2)
+ ? "S"
+ : compare(angle, Math.PI / 4)
+ ? "T"
+ : `Z(${numToStr(angle)})`;
+ if (angle === undefined) angle = Math.PI;
+ // The Bloch sphere Z axis is the Y axis in WebGL
+ return this.applyGate(name, new Vector3(0, 1, 0), angle);
+ }
+
+ rotateH(angle?: number): AppliedGate {
+ const name = angle === undefined ? "H" : `H(${numToStr(angle)})`;
+ if (angle === undefined) angle = Math.PI;
+ // Bloch sphere X & Z axes are the Y and Z axes in WebGL
+ const hAxis = new Vector3(0, 1, 1).normalize();
+ return this.applyGate(name, hAxis, angle);
+ }
+
+ getRotationAtPercent(
+ gate: AppliedGate,
+ percent: number,
+ ): {
+ pos: Quaternion;
+ path: PathEntry[];
+ } {
+ if (percent < 0 || percent > 1) throw Error("Invalid percent");
+
+ // If there is no path, it didn't move. Start and end are the same
+ if (!gate.path.length) return { pos: gate.endPos.clone(), path: [] };
+
+ // Get the path up until this percent. Note that the first element is at
+ // 0%, and the 100% has no entry. For example, if the path has 4 entries
+ // these are at 0, 0.25, 0.5, and 0.75 of the rotation path.
+
+ const stepSize = 1 / gate.path.length;
+ const steps = Math.floor(percent / stepSize);
+
+ // As the first point is at 0%, add one (unless at 100%)
+ const path = gate.path.slice(0, Math.min(steps + 1, gate.path.length));
+ return {
+ pos: gate.path[0].pos.clone().slerp(gate.endPos, percent),
+ path,
+ };
+ }
+}
diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts
index dfdb3b89c2..709b29bb9f 100644
--- a/source/npm/qsharp/ux/index.ts
+++ b/source/npm/qsharp/ux/index.ts
@@ -20,6 +20,7 @@ export { SpaceChart } from "./spaceChart.js";
export { ScatterChart } from "./scatterChart.js";
export { EstimatesOverview } from "./estimatesOverview.js";
export { EstimatesPanel } from "./estimatesPanel.js";
+export { BlochSphere } from "./bloch/bloch.js";
export { Circuit, CircuitPanel } from "./circuit.js";
export { setRenderer, Markdown } from "./renderers.js";
export { Atoms, type ZoneLayout, type TraceData } from "./atoms/index.js";
diff --git a/source/npm/qsharp/ux/qsharp-ux.css b/source/npm/qsharp/ux/qsharp-ux.css
index 8ae3ceca6a..1ec324b728 100644
--- a/source/npm/qsharp/ux/qsharp-ux.css
+++ b/source/npm/qsharp/ux/qsharp-ux.css
@@ -535,6 +535,875 @@ modern-normalize (see https://mattbrictson.com/blog/css-normalize-and-reset for
color: red;
}
+.qs-gate-buttons button {
+ margin-top: 8px;
+ width: 50px;
+ height: 25px;
+}
+
+/* The gate-button toolbar is a row of segmented control groups: the gate
+ palette (X..T†), the undo/redo pair, and reset. Each group is a single
+ continuous "toolbelt" bar -- one rounded, bordered surface -- and the
+ individual gates are transparent segments inside it divided by thin
+ internal rules, so a group reads as one cohesive control rather than a
+ row of separate buttons. */
+.qs-gate-buttons {
+ display: flex;
+ /* The palette, undo/redo, and clear groups must stay together on a
+ single line -- they read as one toolbar. The control panel is sized
+ to fit this row, so it must never wrap. */
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 8px;
+}
+.qs-bloch-gate-group {
+ display: inline-flex;
+ /* The bar surface: the border, rounding, and background live on the
+ group, not on its buttons. `overflow: hidden` clips the segments'
+ hover fill to the rounded outer corners. */
+ border: 1px solid var(--qdk-widget-outline);
+ border-radius: 5px;
+ background: var(--qdk-menu-fill);
+ overflow: hidden;
+}
+/* Buttons become flat, borderless segments of the bar. We override the
+ shared themed-button rules (which give each button its own fill,
+ border, and rounding) so only the group surface shows through. A thin
+ left rule on every segment except the first draws the internal
+ dividers between gates. */
+.qs-gate-buttons .qs-bloch-gate-group button {
+ margin: 0;
+ height: 28px;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ /* Divider between segments. We mix the foreground into the fill rather
+ than using --qdk-widget-outline: in the dark theme the outline token
+ (#444) is identical to --qdk-menu-fill, so an outline-coloured rule
+ would be invisible against the bar. A translucent foreground line
+ reads clearly on both the light and dark button fills. */
+ border-left: 1px solid
+ color-mix(in srgb, var(--qdk-host-foreground) 35%, transparent);
+}
+.qs-gate-buttons .qs-bloch-gate-group button:first-child {
+ border-left: none;
+}
+.qs-gate-buttons .qs-bloch-gate-group button:hover:not(:disabled) {
+ background: var(--qdk-menu-fill-hover);
+}
+/* Keep the focus ring inside the bar so it isn't clipped by the group's
+ `overflow: hidden`, and lift it above neighbouring dividers. */
+.qs-gate-buttons .qs-bloch-gate-group button:focus-visible {
+ outline: 2px solid var(--qdk-focus-border, var(--qdk-atom-fill));
+ outline-offset: -2px;
+ position: relative;
+ z-index: 1;
+}
+/* The gate palette segments share a fixed footprint so the set reads as
+ an even keypad. */
+.qs-gate-buttons .qs-bloch-gate-group-palette button {
+ width: 38px;
+}
+
+/* ---- Bloch widget: themed form controls ----
+ Browser-default