Files
HMDesign/packages/sw/tests/integrated-consumer.mjs
shkim4andClaude Opus 5.5 55179b4577 feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)
SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).

- Build reproducibility: dist text normalized to LF, dist/build-info.json
  (commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
  (eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
  the logo in the existing document-button look, reusing onDocumentOpen and
  documentOpenLabel, no duplicate Open in the dropdown), leading slot,
  hiddenControls sub-items documentNew / documentOpen / layerFreeze /
  layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
  menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
  reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
  SWHeaderPropertyPanel gains children, title, autoFocus and optional
  selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
  (useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
  SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
  overview 52/54 with the two pre-existing historical assertions).

Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:49:19 +09:00

119 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Independent consumer of the 2026-10-19 SW integrated improvement: React 19 + TypeScript + Vite + Tailwind CSS 4
// with BOTH tarballs (@hm-design/sw and @hm-design/ui). Two builds load the same app with the stylesheets in both
// orders (SW first / UI first) to show that they do not change each other. The shipped ai/examples (A–F) are
// copied from node_modules, plus a popup holding HM UI components.
// Usage: node packages/sw/tests/integrated-consumer.mjs <empty-dir-outside-the-repo>
// Build first: npm run build -w @hm-design/sw && npm run build -w @hm-design/ui
import { execFileSync } from "node:child_process"
import { cp, mkdir, readdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
const repoRoot = path.resolve(packageRoot, "../..")
const out = path.resolve(process.argv[2] ?? "")
const relative = path.relative(repoRoot, out)
if (!process.argv[2] || !(relative.startsWith("..") || path.isAbsolute(relative))) throw new Error("Pass an empty directory outside the repository")
await mkdir(out, { recursive: true })
if ((await readdir(out)).length) throw new Error(`${out} is not empty`)
const npm = (args, cwd) => execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd, stdio: "inherit", shell: process.platform === "win32" })
npm(["pack", "--ignore-scripts", "--pack-destination", out], packageRoot)
npm(["pack", "--ignore-scripts", "--pack-destination", out], path.join(repoRoot, "packages/ui"))
const tarballs = (await readdir(out)).filter(name => name.endsWith(".tgz"))
const app = `import { useState } from "react"
import { SWHeaderInput, SWPopup, SWPopupRow, SWPopupSection, SWTheme } from "@hm-design/sw"
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Switch } from "@hm-design/ui"
import { ExampleADefaultHeader } from "./examples/ExampleA-DefaultHeader"
import { ExampleBViewerHeader } from "./examples/ExampleB-ViewerHeader"
import { ExampleCFeatureHeader } from "./examples/ExampleC-FeatureHeader"
import { ExampleDSearchExtension } from "./examples/ExampleD-SearchExtension"
import { ExampleECustomPopup } from "./examples/ExampleE-CustomPopup"
import { ExampleFViewerToolbar } from "./examples/ExampleF-ViewerToolbar"
const examples = { a: ExampleADefaultHeader, b: ExampleBViewerHeader, c: ExampleCFeatureHeader, d: ExampleDSearchExtension, e: ExampleECustomPopup, f: ExampleFViewerToolbar }
function UiPopup({ onEvent }: { onEvent: (name: string) => void }) {
const [snap, setSnap] = useState(true)
const [unit, setUnit] = useState("mm")
const [opacity, setOpacity] = useState([60])
return (
<SWTheme>
<SWPopup title="HM UI in SW Popup" onClose={() => onEvent("ui:close")}>
<SWPopupSection title="SW">
<SWPopupRow label="메모"><SWHeaderInput aria-label="메모" value="A" onCommit={next => onEvent("ui:memo:" + next)} /></SWPopupRow>
</SWPopupSection>
<SWPopupSection title="HM UI">
<SWPopupRow label="스냅" field="none"><Switch aria-label="스냅" checked={snap} onCheckedChange={next => { setSnap(next); onEvent("ui:snap:" + next) }} /></SWPopupRow>
<SWPopupRow label="단위" field="none">
<Select value={unit} onValueChange={next => { setUnit(next); onEvent("ui:unit:" + next) }}>
<SelectTrigger aria-label="단위"><SelectValue /></SelectTrigger>
<SelectContent>{["mm", "cm", "m"].map(item => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
</Select>
</SWPopupRow>
<SWPopupRow label="투명도" field="none"><Slider aria-label="투명도" value={opacity} min={0} max={100} step={10} onValueChange={setOpacity} onValueCommit={next => onEvent("ui:opacity:" + next[0])} /></SWPopupRow>
</SWPopupSection>
<Button size="sm" onClick={() => onEvent("ui:apply")}>적용</Button>
</SWPopup>
</SWTheme>
)
}
export function App() {
const [events, setEvents] = useState<string[]>([])
const onEvent = (name: string) => setEvents(list => [...list, name])
return (
<>
{Object.entries(examples).map(([id, Example]) => (
<section key={id} data-example={id} style={{ marginBottom: 360 }}><Example onEvent={onEvent} /></section>
))}
<section data-example="ui" style={{ padding: 16, background: "#3a3a3a" }}><UiPopup onEvent={onEvent} /></section>
<pre id="events">{events.join("\\n")}</pre>
</>
)
}
`
// The bundler merges the CSS of pages that share modules, so the two stylesheet orders are two builds
// (--mode sw-first / ui-first): "app-order.css" is aliased to the stylesheet entry of that order.
const entry = `import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "app-order.css"
import { App } from "./App"
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>)
`
const viteConfig = `import { fileURLToPath } from "node:url"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
export default defineConfig(({ mode }) => ({
base: "./",
plugins: [react(), tailwindcss()],
resolve: { alias: { "app-order.css": fileURLToPath(new URL(mode === "ui-first" ? "./src/ui-first.css" : "./src/sw-first.css", import.meta.url)) } },
build: { outDir: mode === "ui-first" ? "dist/ui-first" : "dist/sw-first" },
}))
`
const ui = `@import "tailwindcss";\n@import "@hm-design/ui/tailwind.css";\n@import "@hm-design/ui/theme.css";\n`
const sw = `@import "@hm-design/sw/styles.css";\n`
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SW consumer</title><link rel="icon" href="data:," /><style>body { margin: 0; }</style></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\n`
const files = {
"package.json": JSON.stringify({ name: "sw-integrated-consumer", private: true, type: "module", scripts: { typecheck: "tsc -p tsconfig.json", build: "vite build --mode sw-first && vite build --mode ui-first" } }, null, 2),
"tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: false, isolatedModules: true, noUnusedLocals: true, noUnusedParameters: true, types: ["vite/client"] }, include: ["src"] }, null, 2),
"vite.config.ts": viteConfig,
"index.html": html,
"src/sw-first.css": sw + ui,
"src/ui-first.css": ui + sw,
"src/App.tsx": app,
"src/main.tsx": entry,
}
for (const [name, text] of Object.entries(files)) {
await mkdir(path.dirname(path.join(out, name)), { recursive: true })
await writeFile(path.join(out, name), text)
}
npm(["install", "--no-audit", "--no-fund", ...tarballs.map(name => `./${name}`), "react@19.3.0", "react-dom@19.3.0", "tailwindcss@4.3.3"], out)
npm(["install", "--no-audit", "--no-fund", "-D", "typescript@7.0.2", "vite@8.3.0", "@vitejs/plugin-react@6.1.1", "@tailwindcss/vite@4.3.3", "@types/react@19.3.0", "@types/react-dom@19.3.0"], out)
await cp(path.join(out, "node_modules/@hm-design/sw/ai/examples"), path.join(out, "src/examples"), { recursive: true })
npm(["run", "typecheck"], out)
npm(["run", "build"], out)
console.log(`Consumer ready: ${out}. Next: node packages/sw/tests/integrated-consumer.browser.mjs ${out} <evidence-dir>`)