Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
4f630bf
ux: implement fix-plan Phases 0–5 (honesty, deploy safety, provider n…
julia-kafarska Jun 17, 2026
5fda2ba
ux(observability): wire clearEntries to a Clear button in the Monitor…
julia-kafarska Jun 17, 2026
c03dfc0
ux(observability): surface dropped log lines instead of silently capp…
julia-kafarska Jun 17, 2026
ed212e9
ux(a11y): add a polite live region for deploy lifecycle announcements…
julia-kafarska Jun 17, 2026
11977a9
ux(a11y): fix the controls-help modal — document Shift+A, drop dead k…
julia-kafarska Jun 17, 2026
72de70a
ux(ia): add a canonical deploy-status vocabulary and adopt it on the …
julia-kafarska Jun 17, 2026
475d5a3
ux(ia): adopt the canonical deploy-status vocabulary in the status ba…
julia-kafarska Jun 18, 2026
6b47622
ux(a11y): give the zoomed-out poster status a shape glyph + aria-labe…
julia-kafarska Jun 18, 2026
b2611a7
ux(a11y): honour prefers-reduced-motion consistently (AX4)
julia-kafarska Jun 18, 2026
82385d6
ux(a11y): accessible names for the icon-only canvas chrome buttons (A…
julia-kafarska Jun 18, 2026
578e4df
ux(properties): mark required fields in the panel (PE1)
julia-kafarska Jun 18, 2026
97475fe
ux(properties): bring the concept explainer into the panel (PE4)
julia-kafarska Jun 18, 2026
eb6e7ff
ux(properties): reveal advanced-tier props via a disclosure (PE5)
julia-kafarska Jun 18, 2026
34a5731
ux(properties): surface per-field validation as a tab badge (PE2)
julia-kafarska Jun 18, 2026
30d0c24
ux(palette): keyword + category search so goal-based queries resolve …
julia-kafarska Jun 18, 2026
52c25c7
ux(canvas): make Shift+A spotlight match the drag add-path (CD1, CD5,…
julia-kafarska Jun 18, 2026
0c3f914
ux(properties): de-dup the name field + anchor hidden-prop issues (PE…
julia-kafarska Jun 18, 2026
4d7753d
ux(canvas): seed data.name on the fallback add-paths (PE6)
julia-kafarska Jun 18, 2026
181e5b2
ux(deploy): stop logging a fabricated '0.0s' completion time (DF8)
julia-kafarska Jun 18, 2026
4a5d727
ux(observability): log copy fidelity + honest copy feedback (OL7, OL8)
julia-kafarska Jun 18, 2026
0adf912
ux(observability): differentiate the log node's non-streaming states …
julia-kafarska Jun 18, 2026
7bd083a
ux(environments): clarify promotion updates design, not live infra (EI7)
julia-kafarska Jun 18, 2026
7c12890
ux(environments): distinguish a failed status fetch from never-deploy…
julia-kafarska Jun 18, 2026
3e207e6
ux(deploy): don't show a stale 'Connected' pill after a reauth failur…
julia-kafarska Jun 18, 2026
d054d73
ux(deploy): stop wiping partial-destroy failures from the log (DE5)
julia-kafarska Jun 18, 2026
a58fed9
ux(properties): show a 'checking…' cue while validation debounces (PE8)
julia-kafarska Jun 18, 2026
769a342
ux(deploy): add a 'reopen sign-in' recovery to the auth banner (DF9)
julia-kafarska Jun 18, 2026
808bce8
ux(canvas): spotlight section headers + localized category labels (CD8)
julia-kafarska Jun 18, 2026
a7feb56
ux(nav): keep breadcrumbs from flickering to Home during resolution (…
julia-kafarska Jun 18, 2026
6808b3e
ux(nav): wire the advertised /team route (IA8)
julia-kafarska Jun 18, 2026
3c64227
ux(canvas): render dagre routes from all directional organize paths (…
julia-kafarska Jun 18, 2026
babe218
ux(canvas): reflect cardinality limits in drag-time target highlighti…
julia-kafarska Jun 18, 2026
a6b3724
ux(nav): reach project subpages from the tree context menu (IA5)
julia-kafarska Jun 18, 2026
0d86ca4
ux(canvas): keep the connection-rejection tooltip legible when zoomed…
julia-kafarska Jun 18, 2026
3c96074
ux(canvas): add a socket legend to decode the connection language (CCL6)
julia-kafarska Jun 18, 2026
f12fda2
ux(nav): make the active environment a shareable deep-link (IA6)
julia-kafarska Jun 18, 2026
d083a27
perf(nav): resolve the URL path once at the shell, not 4× per page (IA7)
julia-kafarska Jun 18, 2026
d1c58a6
ux(properties): generalize design-requirements via a validation fallb…
julia-kafarska Jun 18, 2026
ed19c2a
R1
julia-kafarska Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ jobs:
- name: Generate Prisma client
run: pnpm --filter @ice/db exec prisma generate

- name: Lint (ESLint)
run: pnpm lint:check

- name: Format check (Prettier)
run: pnpm format:check

- name: Typecheck
run: pnpm typecheck

- name: Unit tests
run: pnpm test:unit
env:
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ pnpm dist:desktop:linux # AppImage + .deb

The `prebuild` script runs `@ice/gateway build` and `@ice/web build` and copies the Prisma client - all three are required because the distributable has to carry the gateway source, the web bundle, and the native Prisma bindings.

To cut a release (build all three platforms, publish to GitHub Releases), use the one-command flow: `pnpm release patch`. See [docs/build/releasing.md](../build/releasing.md).

## Packaging configuration

`apps/desktop/electron-builder.yml` controls what ships:
Expand Down Expand Up @@ -150,4 +152,5 @@ Delete the file to reset the app to first-run state.

- [README.md](README.md) - how the embedded gateway fits.
- [database.md](database.md) - the SQLite schema the desktop uses.
- [docs/build/releasing.md](../build/releasing.md) - cutting a release.
- [ROADMAP.md](../../ROADMAP.md) - signing + notarization status.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ice",
"license": "Apache-2.0",
"version": "0.1.899",
"version": "0.1.900",
"description": "ICE - Integrated Cloud Environment (Web + Backend)",
"private": true,
"type": "module",
Expand All @@ -25,6 +25,7 @@
"dist:desktop:mac": "pnpm --filter @ice/desktop dist:mac",
"dist:desktop:win": "pnpm --filter @ice/desktop dist:win",
"dist:desktop:linux": "pnpm --filter @ice/desktop dist:linux",
"release": "node scripts/release.mjs",
"test:unit": "vitest run --root .",
"test:coverage": "vitest run --root . --coverage",
"test:int": "vitest run --root . --include '**/*.int.test.{ts,tsx}'",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { randomBytes } from 'node:crypto';
import { describe } from 'vitest';
import { AWSDeployer, create_aws_deployer } from '../../aws-deployer';
import { AzureDeployer, create_azure_deployer } from '../../azure-deployer';
import { AlibabaDeployer, create_alibaba_deployer } from '../../alibaba/alibaba-deployer';
import { AlibabaDeployer, type AlibabaDeployOptions, create_alibaba_deployer } from '../../alibaba/alibaba-deployer';
import { DigitalOceanDeployer, create_digitalocean_deployer } from '../../digitalocean/digitalocean-deployer';
import { IBMDeployer, create_ibm_deployer } from '../../ibm/ibm-deployer';
import { KubernetesDeployer, create_kubernetes_deployer } from '../../kubernetes/kubernetes-deployer';
Expand Down Expand Up @@ -293,7 +293,7 @@ export function uniqueIbmName(service: string, maxLen = 63): string {
export async function createAwsDeployer(): Promise<AWSDeployer> {
const region = process.env.AWS_REGION!;
const deployer = create_aws_deployer();
await deployer.initialize({ regions: [region] });
await deployer.initialize({ provider: 'aws', regions: [region] });
return deployer;
}

Expand Down Expand Up @@ -323,6 +323,7 @@ export async function createAzureDeployer(): Promise<AzureLiveContext> {
const resourceGroup = azureTestResourceGroup();
const deployer = create_azure_deployer();
await deployer.initialize({
provider: 'azure',
subscriptions: [subscription],
resource_groups: [resourceGroup],
regions: [location],
Expand Down Expand Up @@ -358,7 +359,7 @@ export interface AlibabaLiveContext {
export async function createAlibabaDeployer(): Promise<AlibabaLiveContext> {
const region = process.env.ALIBABA_CLOUD_REGION!;
const deployer = create_alibaba_deployer();
await deployer.initialize({ provider: 'alibaba', region });
await deployer.initialize({ provider: 'alibaba', region } as AlibabaDeployOptions);
return { deployer, region };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import type { ResourceDeployResult } from '../../../types';

export type LiveProvider = 'aws' | 'azure';
export type LiveProvider = 'aws' | 'azure' | 'kubernetes' | 'alibaba' | 'oci' | 'digitalocean' | 'ibm';

export interface RunStartEvent {
kind: 'run-start';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ function unsupported(

export interface AlibabaDeployOptions extends DeployOptions {
alibaba_credentials?: AlibabaCredentials;
/** Single deploy region. Falls back to ALIBABA_CLOUD_REGION, then the default. */
region?: string;
}

export class AlibabaDeployer implements ProviderDeployer {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/deploy/providers/alibaba/sdk-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export async function initialize_alibaba_clients(
// Attach a thunk per service rather than instantiating up-front: the
// sdk-loader resolves on first `clients.get(<svc>)`.
for (const svc of Object.keys(SERVICE_PACKAGES)) {
const { pkg, endpoint_prefix } = SERVICE_PACKAGES[svc];
const entry = SERVICE_PACKAGES[svc];
if (!entry) continue;
const { pkg, endpoint_prefix } = entry;
let client: unknown = undefined;
const lazyClient = {
async resolve() {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/deploy/providers/aws/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export interface AWSHandlerContext {
* etc.) honour this so a cancel actually stops the remote work.
*/
abort_signal?: AbortSignal;
/**
* Optional IAM role ARN/name for the Lambda CodeBuild fallback path.
* When unset, the builder uses the `codebuild-service-role` default.
*/
codebuild_service_role?: string;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const SDK = '@azure/arm-network';

function extract_vnet_name_from_id(provider_id: string, fallback: string): string {
const match = provider_id.match(/\/virtualNetworks\/([^/]+)\/subnets\//);
return match ? match[1] : fallback;
return match?.[1] ?? fallback;
}

export const subnet_handler: AzureResourceHandler = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ function unsupported(

export interface DODeployOptions extends DeployOptions {
digitalocean_credentials?: DOCredentials;
/** Single deploy region. Falls back to DIGITALOCEAN_REGION, then the default. */
region?: string;
}

export class DigitalOceanDeployer implements ProviderDeployer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { IBMResourceHandler } from '../types';

const SDK = '@ibm-cloud/platform-services';

const PLAN_IDS: Record<string, { service_name: string; plan_id: string }> = {
const PLAN_IDS: Record<'postgresql' | 'mysql' | 'mongodb' | 'redis', { service_name: string; plan_id: string }> = {
postgresql: { service_name: 'databases-for-postgresql', plan_id: '38774ff2-9eef-4ee5-bef5-d8d2d0671c2c' },
mysql: { service_name: 'databases-for-mysql', plan_id: 'standard' },
mongodb: { service_name: 'databases-for-mongodb', plan_id: '4929e7bb-25e9-4ce0-a4f1-c8a87ff39b32' },
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/deploy/providers/ibm/ibm-deployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ function unsupported(

export interface IBMDeployOptions extends DeployOptions {
ibm_credentials?: IBMCredentials;
/** Single deploy region. Falls back to IBMCLOUD_REGION, then the default. */
region?: string;
}

export class IBMDeployer implements ProviderDeployer {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/deploy/providers/ibm/sdk-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export async function initialize_ibm_clients(
const authenticator = await build_authenticator(credentials);
const clients = new Map<string, unknown>();
for (const svc of Object.keys(SERVICE_PACKAGES)) {
const { pkg, clientName } = SERVICE_PACKAGES[svc];
const entry = SERVICE_PACKAGES[svc];
if (!entry) continue;
const { pkg, clientName } = entry;
let client: unknown = undefined;
const lazyClient = {
async resolve() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,6 @@ export function parseCrdProviderId(
fallbackNamespace: string,
): { namespace: string; name: string } {
const parts = provider_id.split('/');
if (parts.length === 5) return { namespace: parts[3], name: parts[4] };
if (parts.length === 5) return { namespace: parts[3] ?? fallbackNamespace, name: parts[4] ?? '' };
return { namespace: fallbackNamespace, name: parts.pop() ?? '' };
}
2 changes: 1 addition & 1 deletion packages/core/src/deploy/providers/kubernetes/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function ensure_namespace(ctx: KubernetesHandlerContext): Promise<s
export function extract_namespace_from_provider_id(provider_id: string, fallback: string): string {
const parts = provider_id.split('/');
// shape: <group>/<kind>/<namespace>/<name>
if (parts.length === 4) return parts[2];
if (parts.length === 4) return parts[2] ?? fallback;
return fallback;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/deploy/providers/oci/oci-deployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ function unsupported(

export interface OCIDeployOptions extends DeployOptions {
oci_credentials?: OCICredentials;
/** Single deploy region. Falls back to OCI_REGION, then the default. */
region?: string;
}

export class OCIDeployer implements ProviderDeployer {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/deploy/providers/oci/sdk-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export async function initialize_oci_clients(
const clients = new Map<string, unknown>();
const provider = await build_auth_provider(credentials);
for (const svc of Object.keys(SERVICE_PACKAGES)) {
const { pkg, clientName } = SERVICE_PACKAGES[svc];
const entry = SERVICE_PACKAGES[svc];
if (!entry) continue;
const { pkg, clientName } = entry;
let client: unknown = undefined;
const lazyClient = {
async resolve() {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,16 @@ describe('executeAiOperations — autoOrganize', () => {
const action = calls.find((c) => c.type === 'cards/autoOrganizeCard');
expect(action.payload.direction).toBe('vertical');
});

// CCL1 — a directional organize must switch to the rectangular edge style so
// the computed dagre routes actually render (else bezier discards them).
it('also switches to the rectangular edge style after organizing', () => {
setCard(makeCard());
const { dispatch, calls } = makeDispatch();
executeAiOperations(dispatch as any, [{ op: 'autoOrganize' }]);
const edge = calls.find((c) => c.type === 'ui/setEdgeStyle');
expect(edge?.payload).toBe('rectangular');
});
});

describe('executeAiOperations — unknown op', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/features/ai/services/operation-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
autoOrganizeCard,
selectActiveCard,
} from '../../../store/slices/cards-slice';
import { setEdgeStyle } from '../../../store/slices/ui-slice';
import type { SkippedOp, ExecutionResult } from './ai-ops/types';
import type { AppDispatch } from '../../../store';
import type { Card } from '../../../store/slices/cards-slice';
Expand Down Expand Up @@ -280,6 +281,7 @@ export function executeAiOperations(

case 'autoOrganize': {
dispatch(autoOrganizeCard({ direction: 'vertical' }));
dispatch(setEdgeStyle('rectangular')); // CCL1 — render the computed dagre routes
executedOps++;
break;
}
Expand Down Expand Up @@ -316,6 +318,7 @@ export function executeAiOperations(
const hasExplicitOrganize = ops.some((o) => o.op === 'autoOrganize');
if (hasStructuralOps && !hasExplicitOrganize && executedOps > 0) {
dispatch(autoOrganizeCard({ direction: 'vertical' }));
dispatch(setEdgeStyle('rectangular')); // CCL1 — render the computed dagre routes
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,35 @@ describe('ConnectionRejectionOverlay', () => {
expect(bodies).toHaveLength(1);
expect((bodies[0].props as { children?: unknown }).children).toBe("Gateway can't connect directly to MySQL");
});

const body = (tree: React.ReactNode) =>
[...walk(tree)].find(
(el) =>
el.type === 'div' && (el.props as { 'data-testid'?: string })['data-testid'] === 'connection-rejection-tooltip',
)!;
const fo = (tree: React.ReactNode) => [...walk(tree)].find((el) => el.type === 'foreignObject')!;

// CCL2 — the overlay lives inside the scaled SVG, so it counter-scales by
// 1/zoom to keep a constant on-screen size (it used to shrink when zoomed out).
it('counter-scales by 1/zoom while staying centred', () => {
const tree = render({ rejection: { x: 500, y: 200, message: 'nope' }, zoom: 0.5 });
const p = fo(tree).props as { x: number; width: number };
expect(p.width).toBe(480); // 240 / 0.5
expect(p.x + p.width / 2).toBe(500); // still centred on rejection.x
const style = (body(tree).props as { style: { transform: string; width: number } }).style;
expect(style.transform).toBe('scale(2)');
expect(style.width).toBe(240); // natural width; the scale fills the foreignObject
});

it('clamps the inverse-zoom so a tiny zoom does not blow up unboundedly', () => {
const tree = render({ rejection: { x: 0, y: 0, message: 'nope' }, zoom: 0.01 });
// invZoom clamped at 1/0.1 = 10 → width 2400, not 24000.
expect((fo(tree).props as { width: number }).width).toBe(2400);
});

it('defaults to no scaling when zoom is omitted', () => {
const tree = render({ rejection: { x: 100, y: 100, message: 'nope' } });
expect((fo(tree).props as { width: number }).width).toBe(240);
expect((body(tree).props as { style: { transform: string } }).style.transform).toBe('scale(1)');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ vi.mock('react', async (importOriginal) => {
}
return [initial, vi.fn()];
}),
// The component now installs an Escape-key effect; no-op it for the
// function-call (tree-walker) render so it doesn't touch a real fiber.
useEffect: vi.fn(),
};
});

Expand Down Expand Up @@ -111,6 +114,19 @@ describe('ControlsHelpModal', () => {
expect((trigger.props as { title: string }).title).toBe('canvas.controls.shortcutsTitle');
});

// AX8/AX6 — the icon-only "?" trigger needs an accessible name (the "?" glyph
// alone announces as "question mark") and an aria-expanded reflecting state.
it('trigger carries an aria-label and aria-expanded reflecting open state', () => {
stateMocks.openValue = false;
const closed = findByType(ControlsHelpModal({}), 'button')[0];
expect((closed.props as { 'aria-label': string })['aria-label']).toBe('canvas.controls.shortcutsTitle');
expect((closed.props as { 'aria-expanded': boolean })['aria-expanded']).toBe(false);

stateMocks.openValue = true;
const open = findByType(ControlsHelpModal({}), 'button')[0];
expect((open.props as { 'aria-expanded': boolean })['aria-expanded']).toBe(true);
});

it('renders the backdrop + panel when open=true', () => {
stateMocks.openValue = true;
const tree = ControlsHelpModal({});
Expand Down Expand Up @@ -198,4 +214,35 @@ describe('ControlsHelpModal', () => {
);
expect(cmd).toHaveLength(1);
});

it('documents the Shift+A add affordance (CD2)', () => {
stateMocks.openValue = true;
const tree = ControlsHelpModal({});
const shiftA = findByPredicate(
tree,
(el) => el.type === 'span' && (el.props as { children?: unknown }).children === 'Shift + A',
);
expect(shiftA).toHaveLength(1);
});

it('no longer advertises the dead 1 / 2 view-level keys (IA3)', () => {
stateMocks.openValue = true;
const tree = ControlsHelpModal({});
const oneOrTwo = findByPredicate(
tree,
(el) =>
el.type === 'span' &&
((el.props as { children?: unknown }).children === '1' ||
(el.props as { children?: unknown }).children === '2'),
);
expect(oneOrTwo).toHaveLength(0);
});

it('marks the panel as a modal dialog (AX3)', () => {
stateMocks.openValue = true;
const tree = ControlsHelpModal({});
const dialog = findByPredicate(tree, (el) => (el.props as { role?: string }).role === 'dialog');
expect(dialog).toHaveLength(1);
expect((dialog[0].props as { 'aria-modal'?: string })['aria-modal']).toBe('true');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ describe('EmptyCanvasOverlay', () => {
expect(onDismiss).toHaveBeenCalledTimes(1);
});

// AX8 — the icon-only dismiss button must carry an accessible name (it renders
// only an <X/> glyph, so screen readers would otherwise announce just "button").
it('the dismiss "X" button has an accessible name (aria-label)', () => {
mocks.templates = [];
const tree = EmptyCanvasOverlay({});
const buttons = findByType(tree, 'button');
expect((buttons[0].props as { 'aria-label'?: string })['aria-label']).toBe('canvas.emptyState.dismiss');
});

it('renders the quickStart label key', () => {
mocks.templates = [];
const tree = EmptyCanvasOverlay({});
Expand Down
Loading
Loading