From d07d5c13fd9cc6fbad6c36393d31774507653869 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 15 Jul 2026 09:55:23 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20mos-creative=20v0.1=20=E2=80=94=20l?= =?UTF-8?q?ocal-first,=20draft-first=20creative=20production=20OSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monorepo (@start-x-work/mos-creative core + web) per §0 audit decisions: Apache-2.0, pnpm/tsup/Biome/Vitest, sessionStorage BYOK, mos-seo theme palette. - core: brief zod schema with a grounding rule, prompt scaffolds, deterministic expression guard (highlight + point to check, no rewrite — D-7 constraint), human-only approval gate (name + flag acknowledgement), exports embedding the approval trail (Markdown / CSV / Marketing-OS JSON / backup). - web: static SPA, browser to Gemini directly (only network destination), IndexedDB storage, two-door footer, disclaimer. No backend, no telemetry, no external write APIs. - CI plus gated release/deploy workflows; new scripts/lint-forbidden.mjs. - audit-cr1v2.md included (§0 audit; records 7 spec contradictions to reconcile). All verification gates green: typecheck, build, 18 unit + 1 e2e tests, biome, lint:forbidden, Gemini-only network, no-rewrite guard, no legacy brand colors. --- .env.example | 5 + .github/workflows/ci.yml | 25 + .github/workflows/deploy.yml | 29 + .github/workflows/release.yml | 36 + .gitignore | 41 + LICENSE | 202 ++ NOTICE | 11 + README.md | 59 + audit-cr1v2.md | 116 + biome.json | 21 + e2e/flow.test.ts | 65 + llms.txt | 18 + package.json | 31 + packages/core/package.json | 39 + packages/core/src/approval.ts | 43 + packages/core/src/brief.test.ts | 47 + packages/core/src/brief.ts | 92 + packages/core/src/export/export.test.ts | 67 + packages/core/src/export/index.ts | 123 + packages/core/src/guard/guard.test.ts | 93 + packages/core/src/guard/guard.ts | 126 + packages/core/src/guard/rules.cosmetics.json | 28 + packages/core/src/guard/rules.finance.json | 25 + packages/core/src/guard/rules.general.json | 38 + .../core/src/guard/rules.health_food.json | 26 + packages/core/src/index.ts | 56 + packages/core/src/prompts/index.ts | 84 + packages/core/src/types.ts | 60 + packages/core/tsconfig.json | 8 + packages/web/index.html | 18 + packages/web/package.json | 32 + packages/web/postcss.config.js | 6 + packages/web/src/App.tsx | 19 + packages/web/src/components/Layout.tsx | 59 + packages/web/src/components/ui/Button.tsx | 17 + packages/web/src/components/ui/Card.tsx | 17 + packages/web/src/components/ui/Field.tsx | 31 + packages/web/src/lib/ai-settings.ts | 24 + packages/web/src/lib/gemini.ts | 41 + packages/web/src/lib/theme.ts | 12 + packages/web/src/main.tsx | 13 + packages/web/src/routes/Home.tsx | 43 + packages/web/src/routes/Settings.tsx | 40 + packages/web/src/routes/Studio.tsx | 385 ++ packages/web/src/storage/db.ts | 57 + packages/web/src/styles/index.css | 28 + packages/web/tailwind.config.ts | 26 + packages/web/tsconfig.json | 19 + packages/web/vite.config.ts | 6 + packages/web/wrangler.toml | 3 + pnpm-lock.yaml | 3118 +++++++++++++++++ pnpm-workspace.yaml | 2 + scripts/lint-forbidden.mjs | 123 + tsconfig.base.json | 14 + 54 files changed, 5767 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 audit-cr1v2.md create mode 100644 biome.json create mode 100644 e2e/flow.test.ts create mode 100644 llms.txt create mode 100644 package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/approval.ts create mode 100644 packages/core/src/brief.test.ts create mode 100644 packages/core/src/brief.ts create mode 100644 packages/core/src/export/export.test.ts create mode 100644 packages/core/src/export/index.ts create mode 100644 packages/core/src/guard/guard.test.ts create mode 100644 packages/core/src/guard/guard.ts create mode 100644 packages/core/src/guard/rules.cosmetics.json create mode 100644 packages/core/src/guard/rules.finance.json create mode 100644 packages/core/src/guard/rules.general.json create mode 100644 packages/core/src/guard/rules.health_food.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/prompts/index.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/web/index.html create mode 100644 packages/web/package.json create mode 100644 packages/web/postcss.config.js create mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/components/Layout.tsx create mode 100644 packages/web/src/components/ui/Button.tsx create mode 100644 packages/web/src/components/ui/Card.tsx create mode 100644 packages/web/src/components/ui/Field.tsx create mode 100644 packages/web/src/lib/ai-settings.ts create mode 100644 packages/web/src/lib/gemini.ts create mode 100644 packages/web/src/lib/theme.ts create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/routes/Home.tsx create mode 100644 packages/web/src/routes/Settings.tsx create mode 100644 packages/web/src/routes/Studio.tsx create mode 100644 packages/web/src/storage/db.ts create mode 100644 packages/web/src/styles/index.css create mode 100644 packages/web/tailwind.config.ts create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/vite.config.ts create mode 100644 packages/web/wrangler.toml create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/lint-forbidden.mjs create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..31f4a41 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Gemini API key (BYOK). +# In the Web UI this is entered in-browser and stored only in sessionStorage. +# This file exists solely for local library/example usage; the key is never sent +# to any Start-X server. The only network destination is Google's Gemini API. +GEMINI_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d1833fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm lint:forbidden + - run: pnpm build + - run: pnpm test + - run: pnpm test:e2e + - run: pnpm typecheck diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0b8795c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: Deploy Web (Cloudflare Pages) + +# Deploys packages/web to Cloudflare Pages (marketing-os-creative). +# +# GATE: the public site must not go live before D-6/D-7 are recorded. +# Keep this workflow_dispatch-only (manual) until the gates pass; switch to +# push:main afterwards if auto-deploy is desired (mirrors the body's habit of +# Cloudflare Git-integration auto-deploy from main). +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy packages/web/dist --project-name=marketing-os-creative diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7a6ac86 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release (npm publish) + +# Publishes @start-x-work/mos-creative to npm. +# +# GATE: do not tag/dispatch a release until the human gates are recorded — +# D-6 (industry checklist approval) and D-7 (patent/trademark clearance). +# See the repo's first-PR audit log and Section X of CR1 v2.0. +# +# Trigger: push a tag like v0.1.0, or run manually. +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: "https://registry.npmjs.org" + - run: pnpm install --frozen-lockfile + - run: pnpm lint:forbidden + - run: pnpm build + - run: pnpm test + - name: Publish core to npm + run: pnpm --filter @start-x-work/mos-creative publish --no-git-checks --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..181d530 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ +.pnp.* + +# Build outputs +dist/ +build/ +.next/ +.vite/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.*.local +.dev.vars +.dev.vars.* +!.dev.vars.example + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test +coverage/ +.nyc_output/ + +# Cloudflare +.wrangler/ +**/.wrangler/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c6af48f --- /dev/null +++ b/NOTICE @@ -0,0 +1,11 @@ +Marketing-OS Creative Toolkit +Copyright 2026 Start-X LLC (Start-X合同会社) + +This product is part of the Marketing-OS ecosystem. +本ソフトウェアは Marketing-OS エコシステムの一部です。 + +For more information, visit https://marketing-os.jp +詳細は https://marketing-os.jp を参照してください。 + +This product is licensed under the Apache License, Version 2.0. +本ソフトウェアは Apache License Version 2.0 の下で提供されます。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9170f0d --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Marketing-OS Creative + +生成は自分のキーで。検査はその場で。決めるのは人。——日本の実務のためのクリエイティブ制作支援 OSS。 + +Generate with your own key. Review in place. A human decides. — a creative-production support OSS built for real-world Japanese practice. + +思想・境界線は **[manifesto](https://github.com/start-x-work/manifesto)** を参照。本ツールは診断・生成の下書き・表現の気づき・人による承認に徹し、外部への投稿・入稿・予約配信は行わない。 + +The Manifesto explains the philosophy and boundary. This tool is limited to drafting, in-place review, and human approval. It never posts, submits, or schedules to any external service. + +## 何をするか / What it does + +- **ブリーフ起点の下書き生成** — 広告コピー / LP 見出し / SNS 投稿案 / メール / 記事構成+本文を、ブリーフ(誰に・何を・どのトーンで)から生成する。 +- **表現ガード(その場の気づき)** — 断定・保証・最上級・ステマ表記欠落・業態別(薬機/景表/金商 等)の観点を、生成と同じ画面でハイライトする。**代替表現の自動提示はしない**(書き換えは人がエディタで行う)。 +- **承認証跡** — `draft → approved → archived`。承認にはガードフラグの確認と承認者名の入力が必須。無人承認の経路はない。 +- **書き出し** — Markdown / CSV / Marketing-OS 連携 JSON。承認証跡を常に同梱。 + +Draft generation from a brief, in-place expression guard (highlight + points to check, **no auto-rewrite**), an approval trail that always requires a reviewer name and flag acknowledgement, and exports that always carry that trail. + +## ローカルファースト・BYOK / Local-first, BYOK + +- あなたの Gemini API キーはブラウザの **sessionStorage** にのみ保存され、Start-X のサーバへは送信されません。 +- 生成物・ブリーフ・承認証跡・ブランドプロファイル・ガード辞書は **IndexedDB**(あなたの手元)に保存されます。 +- ネットワークの送信先は **Gemini API のみ**。テレメトリはありません。バックエンドはありません(Cloudflare Pages 静的配信)。 + +Your key lives only in your browser's sessionStorage; your data lives in IndexedDB. The only network destination is the Gemini API. No telemetry, no backend. + +> **免責 / Disclaimer:** 表現ガードは「気づきの提供」であり、**法的審査を代替するものではありません**。最終判断は人が行ってください。The expression guard surfaces points to check; it does not replace legal review. + +## パッケージ / Packages + +- `packages/core` — `@start-x-work/mos-creative`: ブリーフスキーマ・プロンプト骨格・ガード辞書・書き出しフォーマット(ライブラリ)。 +- `packages/web` — Cloudflare Pages 上の Web UI(このライブラリの最初の利用者)。 + +## 開発 / Development + +```bash +pnpm install +pnpm build +pnpm test +pnpm typecheck +pnpm lint && pnpm lint:forbidden +``` + +Web UI: + +```bash +pnpm --filter @start-x-work/mos-creative-web dev +``` + +## 次を検討しているなら / If you are considering what's next + +継続的な観測と組み合わせるなら **AI CMO**。制作から実行ごと委ねるなら **BPO**。— どちらも [marketing-os.jp](https://marketing-os.jp)。 + +Pair it with ongoing observation via **AI CMO**, or hand off production and execution via **BPO** — both at [marketing-os.jp](https://marketing-os.jp). + +## License + +Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). diff --git a/audit-cr1v2.md b/audit-cr1v2.md new file mode 100644 index 0000000..396d978 --- /dev/null +++ b/audit-cr1v2.md @@ -0,0 +1,116 @@ +# audit-cr1v2.md — CR1 v2.0(mos-creative)§0 リポジトリ監査ログ + +**実施:** 2026-07-15 / セッション: start-x-work スコープ +**対象:** `start-x-work/marketing-os-seo`(OSS規約の踏襲元) +**添付先:** 新リポ `start-x-work/mos-creative` 初回PRの説明(指示書§0の規定) +**結論:** 指示書 CR1 v2.0 の §0/§2/§3 前提に **7件の矛盾** を検出。指示書の規定「audit before spec」に従い、**実装着手前に本書を v2.1 へ改訂**すること。詳細は末尾「要改訂リスト」。 + +--- + +## A. 参照リポの実体(最重要の訂正) + +| 指示書の記述 | 実体 | 対応 | +|---|---|---| +| 参照実装 `start-x-work/mos-seo`/`gh repo clone start-x-work/mos-seo` | **そのリポは存在しない。** 実体は `start-x-work/marketing-os-seo`(npmパッケージ名 `@start-x-work/mos-seo` はこの monorepo 内 `packages/cli` の publish 名) | 参照先を `marketing-os-seo` に訂正 | + +- clone: `git clone --depth 1 https://github.com/start-x-work/marketing-os-seo` → HEAD `e5444e1`、成功。 + +## B. 構成・ビルド・公開設定 + +**monorepo(pnpm workspace `packages/*`)**。指示書§0の「単一構成にするか monorepo にするか(既定=mos-seoに揃える)」の答え = **monorepo に揃える**。 + +``` +marketing-os-seo/ +├─ package.json # private:true, name @start-x-work/marketing-os-seo, v0.0.0(ルートは非公開) +├─ pnpm-workspace.yaml # packages: ["packages/*"] +├─ tsconfig.base.json +├─ biome.json # lint/format = Biome(ESLint/Prettier ではない) +├─ LICENSE (Apache-2.0) +├─ NOTICE # Marketing-OS エコシステムへの導線 +├─ .github/workflows/ci.yml +├─ docs/ (QUICKSTART.md, USAGE.md, ROADMAP.md) +└─ packages/ + ├─ core/ @start-x-work/marketing-os-seo-core v1.1.0 publish:public (tsup, esm+cjs+dts) + │ deps: @start-x-work/mos-kit ^0.1.0, zod ^4.4.3 devDeps: cheerio + ├─ cli/ @start-x-work/mos-seo v1.1.1 publish:public (tsup, bin: mos-seo) + │ deps: citty, picocolors devDeps: core(workspace:*) + └─ web/ @start-x-work/marketing-os-seo-web v0.1.0 private:true (Cloudflare Pages) + React 18 + react-dom 18 + react-router-dom 6 + @tanstack/react-query 5 + lucide-react + Vite 8 + Tailwind 3 + postcss + autoprefixer + wrangler 4(pages deploy) +``` + +- ルート scripts: `build: pnpm -r build` / `test: vitest run` / `lint: biome check .` / `typecheck: pnpm -r typecheck`。packageManager: **pnpm@9.0.0**。 +- CI(`.github/workflows/ci.yml`): node 22 + pnpm、`install --frozen-lockfile → lint → build → test → typecheck`。**禁止用語チェックは存在しない**(下記E)。 +- web の deploy: `wrangler pages deploy dist`、preview: `wrangler pages dev dist`。 + +## C. ライセンス(指示書想定と矛盾) + +- 実体 = **Apache-2.0**(全公開パッケージ・LICENSE・NOTICE すべて Apache-2.0)。 +- 指示書§0/§2の「想定:MIT。異なる場合は本書を改訂」→ **異なる。Apache-2.0 に確定し、指示書を改訂**。 +- NOTICE で Marketing-OS エコシステムへの導線を保持する慣行あり(新リポも踏襲)。 + +## D. BYOK(APIキーの取り扱い)— 指示書と矛盾 + +- 実体 = **sessionStorage**(`packages/web/src/lib/ai-settings.ts`、STORAGE_KEY=`"mos-ai-keys"`、`gemini/openai/anthropic` を JSON 保存)。自社サーバ送信経路なし。 +- 指示書§2-2は「**localStorage** に保存」と記載 → mos-seo 先行実装は **sessionStorage**。指示書§0の「mos-seoに先行実装があればそれに従う」に基づき **sessionStorage に統一**(指示書§2-2を訂正)。 + - 補足: sessionStorage はタブを閉じると消える=より安全側。creative でもキー保存はこの方式を踏襲。生成物・ブリーフ等の永続データは指示書どおり IndexedDB でよい(キーとデータで保存先を分ける現行踏襲)。 + +## E. 禁止用語 lint の有無 + +- mos-seo に `lint-forbidden` 相当は **存在しない**(`find … -name "*forbidden*"` ゼロ、CI にも該当ステップなし)。 +- 指示書§3「lint-forbidden.mjs 相当を OSS 側に**新設**」は妥当(新規作成)。ただし Section X の「禁止用語3語(単一ソース:lint-forbidden.mjs)」は既存前提の書き方 → **新規作成**であることを明記して改訂。 +- lint/format 基盤は **Biome**。CI に `node scripts/lint-forbidden.mjs` ステップを追加する形で組み込む。 + +## F. README・llms.txt(LLMO 雛形) + +- README は日本語+英語混在・**保証/誇大表現なし**・「診断・評価・編集可能な成果物に徹し、自動公開・最終コンテンツ生成はしない」と明記。manifesto ハブへの導線あり。Packages 節・Web UI 節・BYOK 一文あり。→ 新リポの README 雛形として踏襲可能。 +- **llms.txt は存在しない**(ルート・web/public いずれにも無し)。指示書§3の llms.txt は **新規**。「単一ソースから生成」する仕組みは mos-seo に無いので、creative 側で新規設計。 + +## G. ブランド色(指示書「3値のみ」と要調整) + +- 実体(`packages/web/src/lib/theme.ts` / `tailwind.config.ts`)は **3値+セマンティック拡張**: + - コア3値: indigo `#5957EE` / slate `#0A2540` / white `#FFFFFF` + - 拡張: indigoLight `#EEEEFE` / slateMuted `#425466` / border `#E6E9EC` / success `#1A9D62` / warning `#C77700` / danger `#DF1B41` +- 指示書§2-7「使用色は3値のみ」は mos-seo 実態と差異あり。creative は **draft/approved/archived ステータス**と**ガードフラグ表示**を持つため、success/warning/danger が機能的に必要。 + - 決定: 指示書§0の「差異があれば§0監査で確認し Organization として統一」に従い、**mos-seo の `theme.ts` パレットをそのまま採用**(コア3値+セマンティック)。指示書§2-7の「3値のみ」は「コアブランド3値+mos-seo準拠のセマンティック色」に改訂。旧ブランド2値(#1a1a1a/#0052cc)不使用は維持。 + +## H. 本体(private)側の監査 — 実行不能(制約として記録) + +指示書§0 ステップ5〜7(本体 `yamaguchitakehiro0129/Marketing-OS` の CR1 v1.0 着手状況・Gemini 呼び出しパターン・positioning.ts)は **本セッションで実行不能**。 + +- 理由: `add_repo` の **cross-tier 制約** — 本セッションは `start-x-work` オーナー起点のため、別オーナー(`yamaguchitakehiro0129`)配下の private リポを追加できない(v1 仕様)。前フェーズで確認済み。 +- 影響と回避: + - ステップ5(CR1 v1.0 着手済みコードの退避): 本体側の作業。指示書は「本体側 CR1 実装は**中止**」なので、退避対象の有無確認は本体セッションで実施。**mos-creative のOSS実装はこれに依存しない**。 + - ステップ6(Gemini 共通プロンプト): 指示書が「公開リポに直接コピーしない・骨格のみ新規書き起こし」と規定 → 参照不可でも**新規書き起こしが前提**なので実装をブロックしない。 + - ステップ7(positioning.ts 英訳転用): README/対外コピーの転用元。本体セッションで `positioning.ts` に原文追記→英訳転用(N1/C-2と同運用)。creative 初期実装は README を新規記述で先行し、確定コピーは後から差し替え可能。 + +--- + +## §0 で決めること(監査結果に基づく決定) + +- [x] 構成: **monorepo(`packages/core` + `packages/web`、必要なら `packages/cli`)** を mos-seo と同型で採用。 +- [x] ライセンス: **Apache-2.0**(+NOTICE で導線保持)。※指示書「想定MIT」を改訂。 +- [x] BYOKキー保存先: **sessionStorage**(`mos-seo` 準拠。指示書「localStorage」を改訂)。生成物・ブリーフ等の永続データは IndexedDB。 +- [x] 私有リポの持ち出し範囲: **コード・プロンプト原文・業界別ナレッジ・顧客由来データは持ち出さない**。プロンプト骨格・ガード辞書は creative で新規書き起こし(本体セッションが無くても実装可)。 +- [x] lint/format: **Biome**。禁止用語チェックは `scripts/lint-forbidden.mjs` を**新規作成**し CI に追加。 +- [x] 色: **mos-seo `theme.ts` パレットを採用**(コア3値+セマンティック)。指示書§2-7「3値のみ」を改訂。 +- [ ] 本体 CR1 v1.0 着手済みコードの退避: **本体セッションで確認**(cross-tier のため本セッション不能)。 + +## 指示書 CR1 v2.0 → v2.1 要改訂リスト(audit before spec) + +1. 参照リポ名: `start-x-work/mos-seo` → **`start-x-work/marketing-os-seo`**(monorepo。cli の publish 名が mos-seo)。 +2. 構成: 「単一構成 or monorepo」→ **monorepo 確定**(packages/*、pnpm@9、tsup、Biome、Vitest)。 +3. ライセンス: 想定MIT → **Apache-2.0 確定**。 +4. §2-2 BYOK: localStorage → **sessionStorage**(キー保存)。永続データは IndexedDB。 +5. §3/Section X 禁止用語 lint: 「単一ソース:lint-forbidden.mjs」→ **mos-seo に未存在。新規作成**する旨を明記。 +6. §3 llms.txt: mos-seo に未存在 → **新規設計**。 +7. §2-7 色「3値のみ」→ **mos-seo `theme.ts` パレット(3値+セマンティック)採用**。旧2値不使用は維持。 +8. (制約記録)§0 ステップ5〜7 の本体監査は cross-tier で本セッション不能 → **本体セッションで実施**。creative のOSS実装自体はブロックされない。 + +## 実装ブロッカー整理 + +- **技術ブロッカーなし**(monorepo scaffold・core schema・guard 辞書・web は本セッション/新セッションで実装可能。ただし新規リポ作成・npm publish・Pages deploy は外向きアクションのため要判断)。 +- **ガバナンス・ゲート**: 指示書規定により、以下は着手/公開の前提: + - audit before spec: **本監査で矛盾検出 → 指示書 v2.1 改訂が実装着手の前提**。 + - D-6(業態別チェックリスト初期版承認)・D-7(特許・商標リスク確認): **公開(publish/deploy)前の必須ゲート**。ビルド自体は先行可。 + - 上書き記録(v1.0/統合メディア分析戦略書/本書の3箇所): 着手前に記録。← 本体private文書のため本セッション不能。 diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..19abda6 --- /dev/null +++ b/biome.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "files": { + "includes": ["**", "!**/dist", "!**/.wrangler", "!node_modules"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "noUnknownAtRules": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + } +} diff --git a/e2e/flow.test.ts b/e2e/flow.test.ts new file mode 100644 index 0000000..c9cb746 --- /dev/null +++ b/e2e/flow.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + approveRecord, + buildApproval, + buildPrompt, + type CreativeRecord, + emptyBrief, + resolveRuleSets, + runGuard, + toMarketingOsJson, +} from "../packages/core/src/index"; + +// Smoke test for the deterministic Brief → Prompt → Guard → Approve → Export +// flow (no network; the browser adds only the Gemini call in between). +describe("creative flow (headless)", () => { + it("runs end to end and produces an O2-ready export with a trail", () => { + const brief = emptyBrief("ad_copy"); + brief.product.name = "予約くん"; + brief.constraints.industryGuardSet = "general"; + brief.grounding.facts = ["導入後のCVR +12%(2026Q2 自社計測)"]; + + // Prompt embeds the grounding rule. + expect(buildPrompt(brief)).toContain("事実にない数値を創作しない"); + + // A draft (would come from Gemini) is reviewed by the guard. + const draft = "この方法なら必ず成果が出ます。導入後のCVR +12%。"; + const guard = runGuard(draft, resolveRuleSets("general")); + expect(guard.findings.some((f) => f.ruleId === "assertion")).toBe(true); + + // Approval requires name + acknowledgement. + expect(() => + buildApproval({ + approver: "", + flagsAcknowledged: true, + guard, + approvedAt: "2026-07-15T00:00:00Z", + }), + ).toThrow(); + + const record: CreativeRecord = { + id: "rec_e2e", + brief, + generation: { + id: "gen_e2e", + briefId: "brief_e2e", + text: draft, + model: "gemini-2.5-flash", + createdAt: "2026-07-15T00:00:00Z", + }, + status: "draft", + guard, + }; + const approved = approveRecord(record, { + approver: "山口", + flagsAcknowledged: true, + guard, + approvedAt: "2026-07-15T01:00:00Z", + }); + + const out = toMarketingOsJson(approved); + expect(out.schema).toBe("mos-creative/marketing-os-export@1"); + expect(out.approval?.approver).toBe("山口"); + expect(out.guard?.findings.length).toBeGreaterThan(0); + }); +}); diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..3735d19 --- /dev/null +++ b/llms.txt @@ -0,0 +1,18 @@ +# Marketing-OS Creative + +> Local-first, BYOK creative-production support OSS for real-world Japanese practice. Drafts copy/articles from a brief, guards expression in place (highlight + points to check, no auto-rewrite), and requires a human approval trail before anything is considered done. No posting, no submission, no scheduling, no telemetry, no backend. + +## Scope +- Does: brief-driven drafting (ad copy, LP hero, SNS post, email, article), expression guard (assertion/guarantee/superlative/stealth-marketing/industry lenses), approval trail (draft → approved → archived), export (Markdown, CSV, Marketing-OS JSON) with the trail embedded. +- Does not: post/submit/schedule to any external service, auto-approve, auto-rewrite expressions, claim to replace legal review, predict outcomes, or collect telemetry. + +## Model of operation +- BYOK: the user's Gemini API key is stored only in the browser (sessionStorage). The only network destination is the Gemini API. +- Grounding: numeric claims and results must come from human-entered `grounding.facts`; the tool is designed to suppress fabricated numbers. + +## Docs +- README: https://github.com/start-x-work/mos-creative +- Manifesto (philosophy & boundary): https://github.com/start-x-work/manifesto + +## Commercial +- AI CMO (ongoing observation) and BPO (production/execution handoff): https://marketing-os.jp diff --git a/package.json b/package.json new file mode 100644 index 0000000..c69c37c --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "@start-x-work/mos-creative-monorepo", + "version": "0.0.0", + "private": true, + "description": "Local-first, BYOK creative production support OSS — draft-first, human-approved", + "license": "Apache-2.0", + "author": "Start-X LLC ", + "homepage": "https://github.com/start-x-work/mos-creative", + "repository": { + "type": "git", + "url": "https://github.com/start-x-work/mos-creative.git" + }, + "type": "module", + "scripts": { + "build": "pnpm -r build", + "test": "vitest run", + "test:e2e": "vitest run --dir e2e", + "lint": "biome check .", + "lint:forbidden": "node scripts/lint-forbidden.mjs", + "format": "biome format --write .", + "typecheck": "pnpm -r typecheck" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.15", + "@types/node": "^25.9.1", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + }, + "packageManager": "pnpm@9.0.0" +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..cafc3bb --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,39 @@ +{ + "name": "@start-x-work/mos-creative", + "version": "0.1.0", + "private": false, + "type": "module", + "license": "Apache-2.0", + "description": "Brief schema, prompt scaffolds, expression-guard dictionaries, and export formats for local-first, draft-first creative production", + "author": "Start-X LLC ", + "homepage": "https://github.com/start-x-work/mos-creative", + "repository": { + "type": "git", + "url": "https://github.com/start-x-work/mos-creative.git", + "directory": "packages/core" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/packages/core/src/approval.ts b/packages/core/src/approval.ts new file mode 100644 index 0000000..6e9b3ec --- /dev/null +++ b/packages/core/src/approval.ts @@ -0,0 +1,43 @@ +import type { ApprovalTrail, CreativeRecord, GuardReport } from "./types"; + +export class ApprovalError extends Error {} + +export interface ApproveInput { + approver: string; + /** Must be true: the human confirmed they reviewed the guard flags. */ + flagsAcknowledged: boolean; + guard: GuardReport; + /** ISO timestamp supplied by the caller (core never reads the clock). */ + approvedAt: string; +} + +/** + * Build an approval trail. Throws unless BOTH a non-empty approver name and an + * explicit flag acknowledgement are present. There is no code path that yields + * an ApprovalTrail without a human name — i.e. no unattended approval. + */ +export function buildApproval(input: ApproveInput): ApprovalTrail { + if (!input.approver || input.approver.trim() === "") { + throw new ApprovalError("承認者名が必要です(無人承認は不可)。"); + } + if (input.flagsAcknowledged !== true) { + throw new ApprovalError( + "ガードフラグの確認チェックが必要です(無人承認は不可)。", + ); + } + return { + approver: input.approver.trim(), + approvedAt: input.approvedAt, + acknowledgedFindings: input.guard.findings, + findingCountAtApproval: input.guard.findings.length, + }; +} + +/** Transition a record to "approved" by attaching a valid trail. */ +export function approveRecord( + record: CreativeRecord, + input: ApproveInput, +): CreativeRecord { + const approval = buildApproval(input); + return { ...record, status: "approved", guard: input.guard, approval }; +} diff --git a/packages/core/src/brief.test.ts b/packages/core/src/brief.test.ts new file mode 100644 index 0000000..fe20634 --- /dev/null +++ b/packages/core/src/brief.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { emptyBrief, parseBrief } from "./brief"; +import { buildPrompt, groundingPreamble } from "./prompts"; + +describe("brief", () => { + it("fills defaults and validates format", () => { + const b = parseBrief({ + product: { name: "予約くん", summary: "美容室予約" }, + audience: { persona: "小規模サロン店長" }, + format: "ad_copy", + constraints: { industryGuardSet: "general" }, + tone: {}, + grounding: {}, + kbf: ["予約の手軽さ"], + }); + expect(b.version).toBe("1"); + expect(b.tone.style).toBe("敬体"); + expect(b.format).toBe("ad_copy"); + expect(b.grounding.facts).toEqual([]); + }); + + it("rejects an unknown format", () => { + expect(() => parseBrief({ ...emptyBrief(), format: "hologram" })).toThrow(); + }); +}); + +describe("grounding rule in prompts", () => { + it("warns explicitly when no facts are provided", () => { + const p = groundingPreamble(emptyBrief()); + expect(p).toContain("数値・実績・比較優位の主張は書かないでください"); + }); + + it("lists human-entered facts and forbids fabrication", () => { + const b = emptyBrief(); + b.grounding.facts = ["導入後のCVR +12%(2026Q2 自社計測)"]; + const p = groundingPreamble(b); + expect(p).toContain("導入後のCVR +12%"); + expect(p).toContain("事実にない数値を創作しない"); + }); + + it("sns_post prompt keeps URLs and hashtags out (no external posting)", () => { + const b = emptyBrief("sns_post"); + const p = buildPrompt(b); + expect(p).toContain("ハッシュタグは付けず"); + expect(p).toContain("URL も含めない"); + }); +}); diff --git a/packages/core/src/brief.ts b/packages/core/src/brief.ts new file mode 100644 index 0000000..83bba8f --- /dev/null +++ b/packages/core/src/brief.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; + +/** + * brief.json — the single starting point of every generation. + * + * Per CR1 §2-3, the "ad-design" school (start from what to assert, not from a + * free prompt) is the default: generation always originates from a Brief. + * Free-form prompt input is demoted to an auxiliary hint at the app layer. + */ + +/** Content format the brief targets. */ +export const FormatSchema = z.enum([ + "ad_copy", + "lp_hero", + "sns_post", + "email", + "article", + // v0.2 / v0.3 formats are declared here so the schema is forward-stable. + "banner", + "storyboard", +]); +export type Format = z.infer; + +/** Industry guard set names shared with the guard dictionaries. */ +export const IndustryGuardSetSchema = z.enum([ + "general", + "cosmetics", + "health_food", + "finance", +]); +export type IndustryGuardSet = z.infer; + +export const ToneSchema = z.object({ + /** 敬体 / 常体 など。 */ + style: z.string().default("敬体"), + /** 声のトーン(例: 誠実・簡潔)。 */ + voice: z.string().default(""), + /** ユーザー定義の NG ワード。 */ + ngWords: z.array(z.string()).default([]), +}); +export type Tone = z.infer; + +export const BriefSchema = z.object({ + version: z.literal("1").default("1"), + product: z.object({ + name: z.string().default(""), + summary: z.string().default(""), + url: z.string().default(""), + }), + audience: z.object({ + persona: z.string().default(""), + /** ジャーニー段(AISCEAS 等の段名を選択式で)。 */ + journeyStage: z.string().default(""), + }), + /** 訴求する KBF(複数可)。 */ + kbf: z.array(z.string()).default([]), + tone: ToneSchema, + format: FormatSchema, + constraints: z.object({ + maxLength: z.number().int().nonnegative().default(0), + mustInclude: z.array(z.string()).default([]), + industryGuardSet: IndustryGuardSetSchema.default("general"), + }), + grounding: z.object({ + /** + * 事実の手動入力欄。数値・実績・比較優位の主張は、ここに人が入力したものだけを + * 使用してよい(出典のない数値の生成は禁止)。幻覚対策を製品仕様にする。 + */ + facts: z.array(z.string()).default([]), + }), +}); + +export type Brief = z.infer; + +/** Parse + fill defaults. Throws ZodError on invalid input. */ +export function parseBrief(input: unknown): Brief { + return BriefSchema.parse(input); +} + +/** A minimal, valid empty brief for a given format (used to seed the editor). */ +export function emptyBrief(format: Format = "ad_copy"): Brief { + return BriefSchema.parse({ + version: "1", + product: { name: "", summary: "", url: "" }, + audience: { persona: "", journeyStage: "" }, + kbf: [], + tone: { style: "敬体", voice: "", ngWords: [] }, + format, + constraints: { maxLength: 0, mustInclude: [], industryGuardSet: "general" }, + grounding: { facts: [] }, + }); +} diff --git a/packages/core/src/export/export.test.ts b/packages/core/src/export/export.test.ts new file mode 100644 index 0000000..67cf0ba --- /dev/null +++ b/packages/core/src/export/export.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { approveRecord } from "../approval"; +import { emptyBrief } from "../brief"; +import type { CreativeRecord } from "../types"; +import { + fromBackup, + toBackup, + toCsv, + toMarkdown, + toMarketingOsJson, +} from "./index"; + +function sampleRecord(): CreativeRecord { + return { + id: "rec_1", + brief: { + ...emptyBrief("ad_copy"), + product: { name: "予約くん", summary: "", url: "" }, + }, + generation: { + id: "gen_1", + briefId: "brief_1", + text: "予約の手間を、ひとつ減らす。", + model: "gemini-2.x", + createdAt: "2026-07-15T00:00:00Z", + }, + status: "draft", + }; +} + +describe("export always carries the approval trail", () => { + it("markdown includes approver once approved", () => { + const approved = approveRecord(sampleRecord(), { + approver: "山口", + flagsAcknowledged: true, + guard: { findings: [], aiJudged: false }, + approvedAt: "2026-07-15T01:00:00Z", + }); + const md = toMarkdown(approved); + expect(md).toContain("Approved by: 山口"); + }); + + it("marketing-os json carries schema + approval and is O2-ready", () => { + const approved = approveRecord(sampleRecord(), { + approver: "山口", + flagsAcknowledged: true, + guard: { findings: [], aiJudged: false }, + approvedAt: "2026-07-15T01:00:00Z", + }); + const json = toMarketingOsJson(approved); + expect(json.schema).toBe("mos-creative/marketing-os-export@1"); + expect(json.approval?.approver).toBe("山口"); + }); + + it("csv has trail columns", () => { + const csv = toCsv([sampleRecord()]); + expect(csv.split("\n")[0]).toContain("approver"); + expect(csv.split("\n")[0]).toContain("approvedAt"); + }); + + it("backup round-trips", () => { + const backup = toBackup([sampleRecord()]); + const restored = fromBackup(backup); + expect(restored).toHaveLength(1); + expect(restored[0].id).toBe("rec_1"); + }); +}); diff --git a/packages/core/src/export/index.ts b/packages/core/src/export/index.ts new file mode 100644 index 0000000..b186455 --- /dev/null +++ b/packages/core/src/export/index.ts @@ -0,0 +1,123 @@ +import type { CreativeRecord } from "../types"; + +/** + * Export formats. The approval trail is ALWAYS embedded (who / when / which + * flags were acknowledged), per CR1 §2-5. + * + * `toMarketingOsJson` is the single source of the interchange schema consumed by + * the Marketing-OS body's O2 intake (D-class). The schema lives here. + */ + +const MARKETING_OS_SCHEMA = "mos-creative/marketing-os-export@1"; + +export function toMarkdown(record: CreativeRecord): string { + const { brief, generation, status, guard, approval } = record; + const lines: string[] = []; + lines.push(`# ${brief.product.name || "(無題)"} — ${brief.format}`); + lines.push(""); + lines.push(`- Status: ${status}`); + lines.push(`- Model: ${generation.model}`); + lines.push(`- Generated: ${generation.createdAt}`); + if (approval) { + lines.push( + `- Approved by: ${approval.approver} @ ${approval.approvedAt} (${approval.findingCountAtApproval} flag(s) acknowledged)`, + ); + } + lines.push(""); + lines.push("## Draft"); + lines.push(""); + lines.push(generation.text); + if (guard && guard.findings.length > 0) { + lines.push(""); + lines.push("## Guard findings (points to check — not rewrites)"); + lines.push(""); + for (const f of guard.findings) { + const where = f.match ? `「${f.match}」` : "(全体)"; + lines.push( + `- [${f.severity}] ${where} (${f.set}/${f.ruleId}): ${f.point}`, + ); + } + } + return `${lines.join("\n")}\n`; +} + +function csvEscape(value: string): string { + if (/[",\n]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** One row per record. Trail columns are always present. */ +export function toCsv(records: CreativeRecord[]): string { + const header = [ + "id", + "product", + "format", + "status", + "model", + "createdAt", + "approver", + "approvedAt", + "flagsAcknowledged", + "text", + ]; + const rows = records.map((r) => + [ + r.id, + r.brief.product.name, + r.brief.format, + r.status, + r.generation.model, + r.generation.createdAt, + r.approval?.approver ?? "", + r.approval?.approvedAt ?? "", + String(r.approval?.findingCountAtApproval ?? ""), + r.generation.text, + ] + .map((v) => csvEscape(String(v))) + .join(","), + ); + return `${[header.join(","), ...rows].join("\n")}\n`; +} + +export interface MarketingOsExport { + schema: string; + brief: CreativeRecord["brief"]; + generation: CreativeRecord["generation"]; + status: CreativeRecord["status"]; + guard: CreativeRecord["guard"]; + approval: CreativeRecord["approval"]; +} + +/** Single-source interchange JSON for the Marketing-OS body O2 (D-class) intake. */ +export function toMarketingOsJson(record: CreativeRecord): MarketingOsExport { + return { + schema: MARKETING_OS_SCHEMA, + brief: record.brief, + generation: record.generation, + status: record.status, + guard: record.guard, + approval: record.approval, + }; +} + +export interface BackupFile { + schema: string; + records: CreativeRecord[]; +} + +const BACKUP_SCHEMA = "mos-creative/backup@1"; + +/** Whole-project backup; importable for full restore. */ +export function toBackup(records: CreativeRecord[]): BackupFile { + return { schema: BACKUP_SCHEMA, records }; +} + +export function fromBackup(data: unknown): CreativeRecord[] { + const file = data as BackupFile; + if (!file || file.schema !== BACKUP_SCHEMA || !Array.isArray(file.records)) { + throw new Error("Invalid mos-creative backup file."); + } + return file.records; +} diff --git a/packages/core/src/guard/guard.test.ts b/packages/core/src/guard/guard.test.ts new file mode 100644 index 0000000..0c65f28 --- /dev/null +++ b/packages/core/src/guard/guard.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { buildApproval } from "../approval"; +import { resolveRuleSets, runGuard } from "./guard"; + +describe("runGuard", () => { + it("highlights assertions with a point to check, and only advises", () => { + const sets = resolveRuleSets("general"); + const report = runGuard("この方法なら必ず成果が出ます。", sets); + const assertion = report.findings.find((f) => f.ruleId === "assertion"); + expect(assertion).toBeDefined(); + expect(assertion?.match).toBe("必ず"); + expect(assertion?.point).toContain("確認してください"); + // A finding carries ONLY these fields — no field that suggests wording. + expect(Object.keys(assertion ?? {}).sort()).toEqual( + ["index", "match", "point", "ruleId", "set", "severity"].sort(), + ); + }); + + it("adds the industry set on top of general", () => { + const sets = resolveRuleSets("cosmetics"); + const report = runGuard("シミが消えると評判です。", sets); + expect(report.findings.some((f) => f.set === "cosmetics")).toBe(true); + }); + + it("flags missing PR marker via requireOneOf", () => { + const sets = resolveRuleSets("general"); + const report = runGuard("インフルエンサーが絶賛しています。", sets); + expect(report.findings.some((f) => f.ruleId === "stealth_marketing")).toBe( + true, + ); + }); + + it("does not flag stealth marketing when a PR marker is present", () => { + const sets = resolveRuleSets("general"); + const report = runGuard("【PR】新商品のご紹介です。", sets); + expect(report.findings.some((f) => f.ruleId === "stealth_marketing")).toBe( + false, + ); + }); + + it("warns on user-defined NG words", () => { + const sets = resolveRuleSets("general"); + const report = runGuard("激安セール中。", sets, ["激安"]); + expect(report.findings.some((f) => f.ruleId === "user_ng_word")).toBe(true); + }); +}); + +describe("approval gate", () => { + it("rejects approval without a name", () => { + expect(() => + buildApproval({ + approver: "", + flagsAcknowledged: true, + guard: { findings: [], aiJudged: false }, + approvedAt: "2026-07-15T00:00:00Z", + }), + ).toThrow(/承認者名/); + }); + + it("rejects approval without flag acknowledgement", () => { + expect(() => + buildApproval({ + approver: "山口", + flagsAcknowledged: false, + guard: { findings: [], aiJudged: false }, + approvedAt: "2026-07-15T00:00:00Z", + }), + ).toThrow(/確認チェック/); + }); + + it("builds a trail when both are present", () => { + const trail = buildApproval({ + approver: "山口", + flagsAcknowledged: true, + guard: { + findings: [ + { + ruleId: "assertion", + severity: "warning", + match: "必ず", + index: 3, + point: "確認してください", + set: "general", + }, + ], + aiJudged: false, + }, + approvedAt: "2026-07-15T00:00:00Z", + }); + expect(trail.approver).toBe("山口"); + expect(trail.findingCountAtApproval).toBe(1); + }); +}); diff --git a/packages/core/src/guard/guard.ts b/packages/core/src/guard/guard.ts new file mode 100644 index 0000000..816095a --- /dev/null +++ b/packages/core/src/guard/guard.ts @@ -0,0 +1,126 @@ +import type { GuardFinding, GuardReport, GuardSeverity } from "../types"; +import cosmetics from "./rules.cosmetics.json"; +import finance from "./rules.finance.json"; +import general from "./rules.general.json"; +import healthFood from "./rules.health_food.json"; + +/** + * The expression guard. It is deliberately limited to "highlight + point to + * check": it only flags a span and explains what to verify. It never edits the + * text and never proposes alternative wording for the user — the human edits in + * the editor. This is a design constraint (CR1 §2-4, D-7 patent-risk avoidance), + * not an omission. A GuardFinding has no suggestion/alternative field. + */ + +export interface GuardRule { + id: string; + severity: GuardSeverity; + point: string; + /** Any occurrence of these terms raises a finding at each match. */ + terms?: string[]; + /** If NONE of these markers are present, raise a single finding. */ + requireOneOf?: string[]; +} + +export interface GuardRuleSet { + set: string; + note?: string; + rules: GuardRule[]; +} + +export const BUILTIN_RULE_SETS: Record = { + general: general as GuardRuleSet, + cosmetics: cosmetics as GuardRuleSet, + health_food: healthFood as GuardRuleSet, + finance: finance as GuardRuleSet, +}; + +/** Resolve the rule sets to apply: general is always included. */ +export function resolveRuleSets( + industryGuardSet: string, + extra: GuardRuleSet[] = [], +): GuardRuleSet[] { + const sets: GuardRuleSet[] = [BUILTIN_RULE_SETS.general]; + if (industryGuardSet !== "general" && BUILTIN_RULE_SETS[industryGuardSet]) { + sets.push(BUILTIN_RULE_SETS[industryGuardSet]); + } + return [...sets, ...extra]; +} + +function findAll(haystack: string, needle: string): number[] { + if (needle === "") return []; + const out: number[] = []; + let from = 0; + for (;;) { + const i = haystack.indexOf(needle, from); + if (i === -1) break; + out.push(i); + from = i + needle.length; + } + return out; +} + +/** + * Run the deterministic guard over `text`. + * @param userNgWords brief.tone.ngWords — user-defined NG words, always warned. + * @param aiFindings optional findings from an opt-in AI pass (app layer). Merged + * in as-is; core never calls a network itself. + */ +export function runGuard( + text: string, + sets: GuardRuleSet[], + userNgWords: string[] = [], + aiFindings: GuardFinding[] = [], +): GuardReport { + const findings: GuardFinding[] = []; + + for (const rs of sets) { + for (const rule of rs.rules) { + if (rule.terms) { + for (const term of rule.terms) { + for (const index of findAll(text, term)) { + findings.push({ + ruleId: rule.id, + severity: rule.severity, + match: term, + index, + point: rule.point, + set: rs.set, + }); + } + } + } + if (rule.requireOneOf) { + const present = rule.requireOneOf.some((m) => text.includes(m)); + if (!present) { + findings.push({ + ruleId: rule.id, + severity: rule.severity, + match: "", + index: 0, + point: rule.point, + set: rs.set, + }); + } + } + } + } + + for (const ng of userNgWords) { + for (const index of findAll(text, ng)) { + findings.push({ + ruleId: "user_ng_word", + severity: "warning", + match: ng, + index, + point: "ブリーフで指定された NG ワードです。", + set: "user", + }); + } + } + + findings.push(...aiFindings); + findings.sort((a, b) => a.index - b.index); + + return { findings, aiJudged: aiFindings.length > 0 }; +} diff --git a/packages/core/src/guard/rules.cosmetics.json b/packages/core/src/guard/rules.cosmetics.json new file mode 100644 index 0000000..bef7d65 --- /dev/null +++ b/packages/core/src/guard/rules.cosmetics.json @@ -0,0 +1,28 @@ +{ + "set": "cosmetics", + "note": "初期版。薬機法(化粧品)の観点。D-6 承認の対象。法的審査の代替ではなく気づきの提供。", + "rules": [ + { + "id": "cosmetics_efficacy", + "severity": "warning", + "terms": [ + "治る", + "治療", + "改善する", + "効く", + "シミが消える", + "若返る", + "アンチエイジング", + "アトピー", + "ニキビが治る" + ], + "point": "化粧品の効能効果として標榜できる範囲を超える可能性があります。薬機法上、認められた効能の範囲か確認してください。" + }, + { + "id": "cosmetics_medical", + "severity": "warning", + "terms": ["医薬品", "予防", "殺菌", "抗菌", "細胞", "再生"], + "point": "医薬品的な効能を暗示する語です。化粧品の広告表現として適切か確認してください。" + } + ] +} diff --git a/packages/core/src/guard/rules.finance.json b/packages/core/src/guard/rules.finance.json new file mode 100644 index 0000000..c3d21e7 --- /dev/null +++ b/packages/core/src/guard/rules.finance.json @@ -0,0 +1,25 @@ +{ + "set": "finance", + "note": "初期版。金融(金商法・景表法)の観点。D-6 承認の対象。", + "rules": [ + { + "id": "finance_return_guarantee", + "severity": "warning", + "terms": [ + "元本保証", + "必ず儲かる", + "絶対に儲かる", + "リスクなし", + "確実にリターン", + "損しない" + ], + "point": "断定的判断の提供・誇大広告にあたる可能性があります。金商法上の表示規制を確認してください。" + }, + { + "id": "finance_risk_disclosure", + "severity": "info", + "requireOneOf": ["リスク", "元本割れ", "手数料", "注意事項"], + "point": "投資・金融商品に関する内容の場合、リスクや手数料の記載が必要か確認してください。該当しなければ無視できます。" + } + ] +} diff --git a/packages/core/src/guard/rules.general.json b/packages/core/src/guard/rules.general.json new file mode 100644 index 0000000..d705d51 --- /dev/null +++ b/packages/core/src/guard/rules.general.json @@ -0,0 +1,38 @@ +{ + "set": "general", + "note": "初期版。業態非依存の一般的な表現観点。D-6 承認の対象。ユーザー編集可。", + "rules": [ + { + "id": "assertion", + "severity": "warning", + "terms": ["必ず", "確実に", "100%", "絶対", "保証します", "誰でも"], + "point": "断定・保証にあたる可能性があります。景表法(優良誤認・有利誤認)の観点で、根拠と適用条件を確認してください。" + }, + { + "id": "superlative", + "severity": "warning", + "terms": [ + "No.1", + "ナンバーワン", + "日本一", + "業界一", + "最高", + "最安", + "最強" + ], + "point": "最上級表現です。客観的な調査根拠と出典の併記が必要か確認してください。" + }, + { + "id": "urgency", + "severity": "info", + "terms": ["今だけ", "残りわずか", "先着", "限定", "急いで", "今すぐ"], + "point": "希少性・緊急性の表現です。事実と異なる煽りになっていないか確認してください。" + }, + { + "id": "stealth_marketing", + "severity": "info", + "requireOneOf": ["PR", "広告", "提供", "タイアップ", "スポンサー", "#PR"], + "point": "第三者に依頼した宣伝(タイアップ・提供)の場合、ステマ規制上、広告である旨の明示が必要です。該当する場合は PR 表記を追加してください。該当しなければ無視できます。" + } + ] +} diff --git a/packages/core/src/guard/rules.health_food.json b/packages/core/src/guard/rules.health_food.json new file mode 100644 index 0000000..f4dfaa4 --- /dev/null +++ b/packages/core/src/guard/rules.health_food.json @@ -0,0 +1,26 @@ +{ + "set": "health_food", + "note": "初期版。健康食品(薬機法・景表法・健康増進法)の観点。D-6 承認の対象。", + "rules": [ + { + "id": "health_medical_claim", + "severity": "warning", + "terms": [ + "痩せる", + "脂肪燃焼", + "血糖値を下げる", + "病気が治る", + "デトックス", + "免疫力が上がる", + "がんに効く" + ], + "point": "食品で医薬品的な効能を標榜すると、薬機法・健康増進法上の問題となる可能性があります。表現できる範囲か確認してください。" + }, + { + "id": "health_superiority", + "severity": "info", + "terms": ["飲むだけで", "食べるだけで", "誰でも痩せる"], + "point": "効果を過度に断定・簡便化する表現です。景表法(優良誤認)の観点で確認してください。" + } + ] +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..874358f --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,56 @@ +/** + * Public API surface for @start-x-work/mos-creative v0.1+. + * + * Local-first, draft-first creative production: a brief schema, prompt + * scaffolds (with a grounding rule), a deterministic expression guard + * (highlight + point to check — never a rewrite), a human-only approval gate, + * and export formats that always embed the approval trail. + * + * This library performs NO network I/O and reads NO clock: timestamps are + * supplied by callers. The Web UI is the first consumer. + */ + +export { + ApprovalError, + type ApproveInput, + approveRecord, + buildApproval, +} from "./approval"; +export { + type Brief, + BriefSchema, + emptyBrief, + type Format, + FormatSchema, + type IndustryGuardSet, + IndustryGuardSetSchema, + parseBrief, + type Tone, + ToneSchema, +} from "./brief"; +export { + type BackupFile, + fromBackup, + type MarketingOsExport, + toBackup, + toCsv, + toMarkdown, + toMarketingOsJson, +} from "./export/index"; +export { + BUILTIN_RULE_SETS, + type GuardRule, + type GuardRuleSet, + resolveRuleSets, + runGuard, +} from "./guard/guard"; +export { buildPrompt, groundingPreamble } from "./prompts"; +export type { + ApprovalTrail, + CreativeRecord, + CreativeStatus, + Generation, + GuardFinding, + GuardReport, + GuardSeverity, +} from "./types"; diff --git a/packages/core/src/prompts/index.ts b/packages/core/src/prompts/index.ts new file mode 100644 index 0000000..8f4b765 --- /dev/null +++ b/packages/core/src/prompts/index.ts @@ -0,0 +1,84 @@ +import type { Brief, Format } from "../brief"; + +/** + * Prompt scaffolds. Pure functions that turn a Brief into a prompt string. + * + * Written fresh for this OSS (no carry-over of private-repo prompt text, per + * §0 audit decision). The grounding rule (§2-3) is embedded in every prompt so + * the model is structurally discouraged from inventing numbers. + */ + +/** The grounding + boundary preamble shared by all formats. */ +export function groundingPreamble(brief: Brief): string { + const facts = brief.grounding.facts; + const factsBlock = + facts.length > 0 + ? facts.map((f) => `- ${f}`).join("\n") + : "(事実の入力はありません。数値・実績・比較優位の主張は書かないでください。)"; + const ng = + brief.tone.ngWords.length > 0 ? brief.tone.ngWords.join("、") : "(なし)"; + return [ + "あなたは日本語のコピー・記事の下書きを作る編集アシスタントです。", + "以下の規約を厳守してください:", + "1. 数値・実績・比較優位・「No.1」等の主張は、下の【事実】に人が入力したものだけを根拠に使う。事実にない数値を創作しない。", + "2. 断定・保証(「必ず」「100%」等)や誇大表現を避け、確認可能な範囲で書く。", + "3. NG ワードを使わない。指定のトーン・語調に従う。", + "4. これは下書きである。最終確認と公開は人が行う前提で、誇張せず編集しやすい形で出す。", + "", + "【事実(この範囲でのみ数値・実績を使う)】", + factsBlock, + "", + `【トーン】${brief.tone.style} / ${brief.tone.voice || "指定なし"}`, + `【NG ワード】${ng}`, + ].join("\n"); +} + +function audienceBlock(brief: Brief): string { + return [ + `【対象】${brief.audience.persona || "指定なし"}`, + `【ジャーニー段】${brief.audience.journeyStage || "指定なし"}`, + `【訴求 KBF】${brief.kbf.length > 0 ? brief.kbf.join("、") : "指定なし"}`, + `【商品】${brief.product.name || "指定なし"}:${brief.product.summary || ""}`, + ].join("\n"); +} + +function constraintsBlock(brief: Brief): string { + const c = brief.constraints; + const lines: string[] = []; + if (c.maxLength > 0) lines.push(`- 最大 ${c.maxLength} 文字程度に収める`); + if (c.mustInclude.length > 0) + lines.push(`- 次を必ず含める: ${c.mustInclude.join("、")}`); + return lines.length > 0 ? `【制約】\n${lines.join("\n")}` : ""; +} + +const INSTRUCTION: Record = { + ad_copy: + "上記に基づき、広告コピーの案を3つ、それぞれ見出しと本文(短文)で出してください。", + lp_hero: + "上記に基づき、LP のヒーロー見出しとサブコピーの案を3組出してください。", + sns_post: + "上記に基づき、SNS 投稿案を3つ出してください。ハッシュタグは付けず、URL も含めないでください(配信・投稿は人が別途行います)。", + email: + "上記に基づき、メールの件名案を3つと、本文の下書きを1つ出してください。", + article: + "上記に基づき、記事の構成(見出し階層)を作り、続けて本文のドラフトを書いてください。", + banner: + "上記に基づき、バナーに載せる訴求文(メインコピー・サブコピー・CTA)の案を3組出してください。画像そのものは生成しません。", + storyboard: + "上記に基づき、動画の台本とカット割り(各カットの説明)を作ってください。映像そのものは生成しません。", +}; + +/** Build the full prompt for a brief's format. */ +export function buildPrompt(brief: Brief): string { + return [ + groundingPreamble(brief), + "", + audienceBlock(brief), + "", + constraintsBlock(brief), + "", + INSTRUCTION[brief.format], + ] + .filter((s) => s !== "") + .join("\n"); +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..6ed21e0 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,60 @@ +import type { Brief } from "./brief"; + +/** Status lifecycle. No path exists that reaches "approved" without a human. */ +export type CreativeStatus = "draft" | "approved" | "archived"; + +/** A single generated candidate (text-first in v0.1). */ +export interface Generation { + id: string; + briefId: string; + /** The generated text body (draft). */ + text: string; + /** Which model produced it, for the trail (e.g. "gemini-2.x"). */ + model: string; + /** ISO timestamp; supplied by the caller (core does not read the clock). */ + createdAt: string; +} + +/** Severity of a guard finding. Never "error" — the guard only advises. */ +export type GuardSeverity = "info" | "warning"; + +/** A guard finding: a highlighted span plus the point to check. NO rewrite. */ +export interface GuardFinding { + /** Rule id, e.g. "assertion", "superlative", "stealth_marketing". */ + ruleId: string; + severity: GuardSeverity; + /** The matched substring in the reviewed text. */ + match: string; + /** Character offset of the match within the reviewed text. */ + index: number; + /** The point a human should check — an advisory, never a replacement string. */ + point: string; + /** Which dictionary raised it, e.g. "general" or "cosmetics". */ + set: string; +} + +export interface GuardReport { + findings: GuardFinding[]; + /** True if AI-based contextual judging was requested and merged in. */ + aiJudged: boolean; +} + +/** Approval requires BOTH a reviewer name AND explicit flag acknowledgement. */ +export interface ApprovalTrail { + approver: string; + approvedAt: string; + /** The guard findings the approver acknowledged at approval time. */ + acknowledgedFindings: GuardFinding[]; + /** How many findings were shown; kept for auditability. */ + findingCountAtApproval: number; +} + +/** A creative unit: brief + chosen draft + (once approved) its trail. */ +export interface CreativeRecord { + id: string; + brief: Brief; + generation: Generation; + status: CreativeStatus; + guard?: GuardReport; + approval?: ApprovalTrail; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..c99ec7b --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..ac86f11 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Marketing-OS Creative + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..908a8a0 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@start-x-work/mos-creative-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "wrangler pages dev dist", + "deploy": "wrangler pages deploy dist", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@start-x-work/mos-creative": "workspace:*", + "lucide-react": "^1.17.0", + "react": "18", + "react-dom": "18", + "react-router-dom": "6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260607.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "tailwindcss": "3", + "vite": "^8.0.16", + "wrangler": "^4.98.0" + } +} diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/packages/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..8e6c415 --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,19 @@ +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { Layout } from "./components/Layout"; +import { Home } from "./routes/Home"; +import { Settings } from "./routes/Settings"; +import { Studio } from "./routes/Studio"; + +export function App() { + return ( + + + + } /> + } /> + } /> + + + + ); +} diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx new file mode 100644 index 0000000..c8d03d4 --- /dev/null +++ b/packages/web/src/components/Layout.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; + +export function Layout({ children }: { children: ReactNode }) { + return ( +
+
+
+ + Marketing-OS Creative + + +
+
+ +
{children}
+ +
+
+

+ キーも生成物も、あなたの手元から出ません(キー = sessionStorage、 + データ = IndexedDB、送信先 = Gemini API のみ)。 +

+

+ 表現ガードは気づきの提供であり、法的な審査に代わるものではありません。 + 最終判断は人が行ってください。 +

+

+ 次を検討するなら:{" "} + + AI CMO + + (継続的な観測と組み合わせる) /{" "} + + BPO + + (制作から実行ごと委ねる)。 +

+

+ + Manifesto + {" "} + · Apache-2.0 +

+
+
+
+ ); +} diff --git a/packages/web/src/components/ui/Button.tsx b/packages/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..0932e09 --- /dev/null +++ b/packages/web/src/components/ui/Button.tsx @@ -0,0 +1,17 @@ +import type { ButtonHTMLAttributes } from "react"; + +type Variant = "primary" | "secondary"; + +export function Button({ + variant = "primary", + className = "", + ...props +}: ButtonHTMLAttributes & { variant?: Variant }) { + const base = + "inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-40 disabled:cursor-not-allowed"; + const styles = + variant === "primary" + ? "bg-indigo text-white hover:opacity-90" + : "border border-border bg-white text-slate hover:bg-indigo-light"; + return