diff --git a/AGENTS.md b/AGENTS.md
index 61230bf..9588fc4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,3 +61,10 @@
declares TypeScript `^4.9.0 || ^5.0.0`, so keep the TypeScript 6
`peerDependencyRules.allowedVersions` entry unless the upstream peer range is
updated.
+- DevTools segmented override buttons should keep their active border as a
+ complete `border` shorthand. Mixing `border` with `borderColor` in the inline
+ style object triggers React development warnings during override rerenders.
+- Demo grids that contain horizontally scrollable flag tables need `min-width:
+ 0` on the grid item that owns the table; otherwise mobile layouts can report
+ document-level horizontal overflow even when the table itself is intended to
+ scroll internally.
diff --git a/apps/mf-host/src/App.tsx b/apps/mf-host/src/App.tsx
index ccd982c..ea56b3e 100644
--- a/apps/mf-host/src/App.tsx
+++ b/apps/mf-host/src/App.tsx
@@ -82,11 +82,7 @@ function HostShell() {
};
return (
- <>
-
- Skip to content
-
-
+
- >
+
);
}
diff --git a/apps/mf-host/src/latchClient.test.ts b/apps/mf-host/src/latchClient.test.ts
index 668b62e..1d5a150 100644
--- a/apps/mf-host/src/latchClient.test.ts
+++ b/apps/mf-host/src/latchClient.test.ts
@@ -1,3 +1,5 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it } from "vitest";
import {
defaultFlags,
@@ -57,3 +59,19 @@ describe("module federation host flag client", () => {
});
});
});
+
+describe("module federation host shell", () => {
+ it("keeps the skip link owned by the static HTML shell", () => {
+ const indexHtml = readFileSync(
+ fileURLToPath(new URL("../index.html", import.meta.url)),
+ "utf8"
+ );
+ const appSource = readFileSync(
+ fileURLToPath(new URL("./App.tsx", import.meta.url)),
+ "utf8"
+ );
+
+ expect(indexHtml.match(/Skip to content/g) ?? []).toHaveLength(1);
+ expect(appSource).not.toContain("Skip to content");
+ });
+});
diff --git a/apps/mf-host/src/styles.css b/apps/mf-host/src/styles.css
index c907d8b..b57dfb1 100644
--- a/apps/mf-host/src/styles.css
+++ b/apps/mf-host/src/styles.css
@@ -67,7 +67,8 @@ button {
.mf-shell {
width: min(1480px, 100%);
margin: 0 auto;
- padding: max(24px, env(safe-area-inset-top)) clamp(16px, 4vw, 52px) 52px;
+ padding: max(24px, env(safe-area-inset-top)) clamp(16px, 4vw, 52px)
+ max(52px, env(safe-area-inset-bottom));
}
.mf-header {
@@ -135,16 +136,22 @@ h2 {
color: #18231e;
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset;
+ transition:
+ background 160ms ease,
+ border-color 160ms ease,
+ transform 160ms ease;
}
.button:hover {
border-color: rgba(31, 53, 42, 0.42);
background: rgba(255, 255, 255, 0.82);
+ transform: translateY(-1px);
}
.button:disabled {
cursor: wait;
color: #5f6b64;
+ transform: none;
}
.button.primary {
@@ -203,6 +210,7 @@ h2 {
border: 1px solid rgba(31, 53, 42, 0.16);
border-radius: 8px;
background: rgba(31, 53, 42, 0.16);
+ box-shadow: 0 16px 42px rgba(31, 53, 42, 0.06);
}
.status-pill {
@@ -266,6 +274,10 @@ h2 {
overflow: hidden;
}
+.flag-layer-panel {
+ min-width: 0;
+}
+
.remote-slot-heading {
display: flex;
justify-content: space-between;
@@ -279,6 +291,8 @@ h2 {
color: #64756b;
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
+ overflow-wrap: anywhere;
+ text-align: right;
}
.remote-fallback {
@@ -294,7 +308,7 @@ h2 {
.host-workspace {
display: grid;
- grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
+ grid-template-columns: minmax(0, 1.1fr) minmax(420px, 0.9fr);
gap: 18px;
align-items: start;
margin-top: 18px;
@@ -382,6 +396,7 @@ h2 {
}
.devtools-panel {
+ min-width: 0;
padding: 14px;
}
@@ -390,7 +405,7 @@ h2 {
min-height: 340px;
display: grid;
gap: 18px;
- padding: 22px;
+ padding: clamp(18px, 3vw, 24px);
color: #18231e;
}
@@ -613,6 +628,14 @@ h2 {
display: grid;
}
+ .remote-slot-heading span {
+ text-align: left;
+ }
+
+ .section-heading p {
+ max-width: none;
+ }
+
.button {
width: 100%;
}
diff --git a/apps/mf-remote-dashboard/src/styles.css b/apps/mf-remote-dashboard/src/styles.css
index 15265d1..2e4aae1 100644
--- a/apps/mf-remote-dashboard/src/styles.css
+++ b/apps/mf-remote-dashboard/src/styles.css
@@ -51,14 +51,15 @@ a {
.standalone-remote {
width: min(820px, 100%);
margin: 0 auto;
- padding: max(24px, env(safe-area-inset-top)) 16px 40px;
+ padding: max(24px, env(safe-area-inset-top)) 16px
+ max(40px, env(safe-area-inset-bottom));
}
.dashboard-remote {
min-height: 340px;
display: grid;
gap: 18px;
- padding: 22px;
+ padding: clamp(18px, 3vw, 24px);
color: #18231e;
background:
linear-gradient(135deg, rgba(218, 235, 222, 0.78), transparent 46%),
diff --git a/apps/mf-remote-settings/src/styles.css b/apps/mf-remote-settings/src/styles.css
index 479a849..2a88dc2 100644
--- a/apps/mf-remote-settings/src/styles.css
+++ b/apps/mf-remote-settings/src/styles.css
@@ -51,7 +51,8 @@ a {
.standalone-remote {
width: min(820px, 100%);
margin: 0 auto;
- padding: max(24px, env(safe-area-inset-top)) 16px 40px;
+ padding: max(24px, env(safe-area-inset-top)) 16px
+ max(40px, env(safe-area-inset-bottom));
}
.settings-remote {
@@ -59,7 +60,7 @@ a {
display: grid;
align-content: start;
gap: 18px;
- padding: 22px;
+ padding: clamp(18px, 3vw, 24px);
color: #18231e;
background:
linear-gradient(135deg, rgba(226, 234, 210, 0.82), transparent 48%),
diff --git a/apps/playground/src/App.tsx b/apps/playground/src/App.tsx
index b6405a0..438f5cf 100644
--- a/apps/playground/src/App.tsx
+++ b/apps/playground/src/App.tsx
@@ -72,7 +72,7 @@ function LatchPlayground() {
+
+
Backend envelope
@@ -176,10 +180,6 @@ function LatchPlayground() {
{JSON.stringify(normalizedPreview, null, 2)}
-
-
);
}
diff --git a/apps/playground/src/styles.css b/apps/playground/src/styles.css
index f62b51b..cf8b52d 100644
--- a/apps/playground/src/styles.css
+++ b/apps/playground/src/styles.css
@@ -1,6 +1,6 @@
:root {
color: #1c2822;
- background: #f6f7f2;
+ background: #f4f7f1;
font-family:
"Avenir Next",
"Segoe UI",
@@ -17,7 +17,7 @@
html {
min-width: 320px;
- background: #f6f7f2;
+ background: #f4f7f1;
}
body {
@@ -63,7 +63,7 @@ button {
.app-shell {
width: min(1480px, 100%);
margin: 0 auto;
- padding: max(24px, env(safe-area-inset-top)) clamp(16px, 4vw, 48px)
+ padding: max(24px, env(safe-area-inset-top)) clamp(16px, 4vw, 50px)
max(32px, env(safe-area-inset-bottom));
}
@@ -157,6 +157,17 @@ h3 {
border-color: rgba(28, 40, 34, 0.18);
}
+.button.danger {
+ color: #6f2028;
+ border-color: rgba(125, 36, 45, 0.24);
+ background: #fff8f7;
+}
+
+.button.danger:hover {
+ border-color: rgba(125, 36, 45, 0.44);
+ background: #fff2f0;
+}
+
.spinner {
width: 16px;
height: 16px;
@@ -219,7 +230,7 @@ h3 {
.workspace-grid {
display: grid;
- grid-template-columns: minmax(280px, 0.88fr) minmax(300px, 1fr) minmax(320px, 1.2fr);
+ grid-template-columns: minmax(280px, 0.88fr) minmax(300px, 1fr) minmax(360px, 1.08fr);
gap: 14px;
align-items: start;
}
@@ -235,13 +246,20 @@ h3 {
.panel-heading {
display: flex;
- align-items: baseline;
+ align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid rgba(28, 40, 34, 0.12);
}
+.panel-heading .subtle {
+ min-width: 0;
+ max-width: 48%;
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
.flag-list {
display: grid;
}
@@ -333,15 +351,15 @@ h3 {
}
.payload-panel {
- grid-row: span 2;
+ min-width: 0;
}
.devtools-section {
- margin-top: 16px;
+ min-width: 0;
}
pre {
- max-height: 560px;
+ max-height: 360px;
margin: 0;
overflow: auto;
padding: 18px;
@@ -367,6 +385,10 @@ pre {
.payload-panel {
grid-row: auto;
}
+
+ .devtools-section {
+ grid-column: span 2;
+ }
}
@media (max-width: 760px) {
@@ -386,6 +408,20 @@ pre {
grid-template-columns: 1fr;
}
+ .devtools-section {
+ grid-column: auto;
+ }
+
+ .panel-heading {
+ display: grid;
+ gap: 6px;
+ }
+
+ .panel-heading .subtle {
+ max-width: none;
+ text-align: left;
+ }
+
h1 {
font-size: 2.5rem;
}
diff --git a/packages/devtools/src/index.tsx b/packages/devtools/src/index.tsx
index ea58a42..d61be7a 100644
--- a/packages/devtools/src/index.tsx
+++ b/packages/devtools/src/index.tsx
@@ -24,7 +24,8 @@ export const LatchDevTools = memo(function LatchDevTools({
title = "Latch DevTools"
}: LatchDevToolsProps) {
const snapshot = useFlagClientSnapshot(client);
- const hasOverrides = Object.keys(snapshot.overrides).length > 0;
+ const overrideCount = Object.keys(snapshot.overrides).length;
+ const hasOverrides = overrideCount > 0;
const clearAllOverrides = useCallback(() => {
client.clearOverrides();
@@ -46,6 +47,10 @@ export const LatchDevTools = memo(function LatchDevTools({
{snapshot.errorMessage ?? "No fetch error"}
+
+
Overrides
+ {overrideCount}
+
Remote
Initial
Override
- Toggle
+ Set override
Actions
@@ -105,13 +110,12 @@ function FlagRow({
detail: FlagDetails;
}) {
const canToggle = typeof detail.value === "boolean";
- const toggleOverride = useCallback(() => {
- if (typeof detail.value !== "boolean") {
- return;
- }
-
- client.setOverride(detail.key, !detail.value);
- }, [client, detail.key, detail.value]);
+ const setOverrideOn = useCallback(() => {
+ client.setOverride(detail.key, true);
+ }, [client, detail.key]);
+ const setOverrideOff = useCallback(() => {
+ client.setOverride(detail.key, false);
+ }, [client, detail.key]);
const clearOverride = useCallback(() => {
client.clearOverride(detail.key);
}, [client, detail.key]);
@@ -129,13 +133,32 @@ function FlagRow({
{canToggle ? (
-
+
+
+ On
+
+
+ Off
+
+
) : (
unset
@@ -237,15 +260,16 @@ function formatFlagValue(value: FlagValue | undefined): string {
const styles = {
button: {
alignItems: "center",
- background: "#172033",
- border: "1px solid #172033",
+ background: "#1f5f46",
+ border: "1px solid #1f5f46",
borderRadius: 6,
- color: "#f8fafc",
+ color: "#f7fbf6",
cursor: "pointer",
display: "inline-flex",
font: "inherit",
- minHeight: 32,
- padding: "0 12px",
+ fontWeight: 700,
+ minHeight: 36,
+ padding: "0 14px",
touchAction: "manipulation"
},
caption: {
@@ -258,24 +282,17 @@ const styles = {
width: 1
},
cell: {
- borderTop: "1px solid #e2e8f0",
+ borderTop: "1px solid #dde7df",
fontVariantNumeric: "tabular-nums",
padding: "10px 12px",
textAlign: "left",
verticalAlign: "middle",
whiteSpace: "nowrap"
},
- checkbox: {
- accentColor: "#172033",
- cursor: "pointer",
- height: 18,
- margin: 0,
- width: 18
- },
columnHeader: {
- background: "#f8fafc",
- borderTop: "1px solid #e2e8f0",
- color: "#475569",
+ background: "#f4f7f1",
+ borderTop: "1px solid #dde7df",
+ color: "#52675b",
fontSize: 12,
fontWeight: 700,
letterSpacing: 0,
@@ -289,14 +306,14 @@ const styles = {
opacity: 0.45
},
emptyCell: {
- borderTop: "1px solid #e2e8f0",
- color: "#64748b",
+ borderTop: "1px solid #dde7df",
+ color: "#64756b",
padding: 16,
textAlign: "center"
},
flagCell: {
- borderTop: "1px solid #e2e8f0",
- color: "#0f172a",
+ borderTop: "1px solid #dde7df",
+ color: "#17231d",
fontWeight: 700,
padding: "10px 12px",
textAlign: "left",
@@ -306,14 +323,15 @@ const styles = {
ghostButton: {
alignItems: "center",
background: "#ffffff",
- border: "1px solid #cbd5e1",
+ border: "1px solid #cbd8ce",
borderRadius: 6,
- color: "#172033",
+ color: "#17231d",
cursor: "pointer",
display: "inline-flex",
font: "inherit",
- minHeight: 28,
- padding: "0 10px",
+ fontWeight: 700,
+ minHeight: 32,
+ padding: "0 11px",
touchAction: "manipulation"
},
header: {
@@ -325,10 +343,10 @@ const styles = {
},
shell: {
background: "#ffffff",
- border: "1px solid #cbd5e1",
+ border: "1px solid #cbd8ce",
borderRadius: 8,
- boxShadow: "0 16px 40px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.08)",
- color: "#172033",
+ boxShadow: "0 18px 44px rgba(31, 53, 42, 0.09), 0 1px 2px rgba(31, 53, 42, 0.08)",
+ color: "#17231d",
fontFamily:
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontSize: 13,
@@ -336,10 +354,10 @@ const styles = {
overflow: "hidden"
},
sourceBadge: {
- background: "#eef2ff",
- border: "1px solid #c7d2fe",
+ background: "#e4f1e8",
+ border: "1px solid #c5decf",
borderRadius: 999,
- color: "#3730a3",
+ color: "#1f5f46",
display: "inline-flex",
fontSize: 12,
fontWeight: 700,
@@ -347,13 +365,41 @@ const styles = {
minHeight: 20,
padding: "0 8px"
},
+ segmentedControl: {
+ alignItems: "center",
+ background: "#eef4ef",
+ border: "1px solid #cbd8ce",
+ borderRadius: 7,
+ display: "inline-flex",
+ gap: 2,
+ padding: 2
+ },
+ segmentButton: {
+ background: "transparent",
+ border: "1px solid transparent",
+ borderRadius: 5,
+ color: "#52675b",
+ cursor: "pointer",
+ font: "inherit",
+ fontWeight: 760,
+ minHeight: 30,
+ minWidth: 44,
+ padding: "0 9px",
+ touchAction: "manipulation"
+ },
+ segmentButtonActive: {
+ background: "#ffffff",
+ border: "1px solid #9bbca8",
+ color: "#17231d",
+ boxShadow: "0 1px 2px rgba(31, 53, 42, 0.12)"
+ },
statusItem: {
display: "flex",
gap: 6,
margin: 0
},
statusLabel: {
- color: "#64748b",
+ color: "#64756b",
margin: 0
},
statusList: {
@@ -363,13 +409,13 @@ const styles = {
padding: 0
},
statusValue: {
- color: "#172033",
+ color: "#17231d",
fontWeight: 700,
margin: 0
},
table: {
borderCollapse: "collapse",
- minWidth: 760,
+ minWidth: 860,
width: "100%"
},
tableScroll: {
@@ -377,7 +423,7 @@ const styles = {
overscrollBehaviorX: "contain"
},
title: {
- color: "#0f172a",
+ color: "#17231d",
fontSize: 16,
lineHeight: "22px",
margin: 0
diff --git a/packages/devtools/test/LatchDevTools.test.tsx b/packages/devtools/test/LatchDevTools.test.tsx
index 7ae671a..740c7c2 100644
--- a/packages/devtools/test/LatchDevTools.test.tsx
+++ b/packages/devtools/test/LatchDevTools.test.tsx
@@ -1,3 +1,4 @@
+import { readFileSync } from "node:fs";
import { Profiler } from "react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -42,7 +43,7 @@ describe("LatchDevTools", () => {
expect(screen.getByRole("row", { name: /searchV2 true override true unset false true/i })).not.toBeNull();
});
- it("toggles boolean overrides", () => {
+ it("sets boolean overrides from the table", () => {
const client = createFlagClient({
defaults: {
billingPortal: false
@@ -50,19 +51,91 @@ describe("LatchDevTools", () => {
});
render( );
- const toggle = screen.getByLabelText("Override billingPortal") as HTMLInputElement;
+ const forceOn = screen.getByRole("button", {
+ name: "Set billingPortal override on"
+ });
- expect(toggle.checked).toBe(false);
+ expect(forceOn.getAttribute("aria-pressed")).toBe("false");
- fireEvent.click(toggle);
+ fireEvent.click(forceOn);
expect(client.getOverrides()).toEqual({
billingPortal: true
});
- expect(toggle.checked).toBe(true);
+ expect(forceOn.getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("row", { name: /billingPortal true override false unset unset true/i })).not.toBeNull();
});
+ it("sets boolean override values with explicit on and off actions", () => {
+ const client = createFlagClient({
+ defaults: {
+ billingPortal: false
+ }
+ });
+
+ render( );
+ const forceOn = screen.getByRole("button", {
+ name: "Set billingPortal override on"
+ });
+ const forceOff = screen.getByRole("button", {
+ name: "Set billingPortal override off"
+ });
+
+ fireEvent.click(forceOn);
+
+ expect(client.getOverrides()).toEqual({
+ billingPortal: true
+ });
+ expect(forceOn.getAttribute("aria-pressed")).toBe("true");
+ expect(forceOff.getAttribute("aria-pressed")).toBe("false");
+
+ fireEvent.click(forceOff);
+
+ expect(client.getOverrides()).toEqual({
+ billingPortal: false
+ });
+ expect(forceOn.getAttribute("aria-pressed")).toBe("false");
+ expect(forceOff.getAttribute("aria-pressed")).toBe("true");
+ });
+
+ it("does not mix border shorthand and longhand styles when override actions update", () => {
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const client = createFlagClient({
+ defaults: {
+ billingPortal: false
+ }
+ });
+
+ try {
+ render( );
+ fireEvent.click(screen.getByRole("button", {
+ name: "Set billingPortal override on"
+ }));
+ expect(
+ screen
+ .getByRole("button", { name: "Set billingPortal override on" })
+ .getAttribute("style")
+ ).not.toContain("border-color");
+ fireEvent.click(screen.getByRole("button", {
+ name: "Set billingPortal override off"
+ }));
+
+ expect(
+ consoleError.mock.calls.some(([message]) =>
+ String(message).includes("Removing a style property")
+ )
+ ).toBe(false);
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("keeps segment button styles on border shorthand to avoid React style warnings", () => {
+ const source = readFileSync("src/index.tsx", "utf8");
+
+ expect(source).not.toMatch(/segmentButtonActive:\s*{[^}]*borderColor/s);
+ });
+
it("updates when an override changes inspection state without changing the effective value", () => {
const client = createFlagClient({
defaults: {