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 - -
+

Module federation demo

@@ -189,8 +185,7 @@ function HostShell() {
-
- +
); } 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}
+
+ + ) : ( 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: {