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>
119 lines
7.4 KiB
JavaScript
119 lines
7.4 KiB
JavaScript
// 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>`)
|