diff --git a/docs/routes.json b/docs/routes.json index 11737d1..ebe4049 100644 --- a/docs/routes.json +++ b/docs/routes.json @@ -154,6 +154,21 @@ "lang": "ja", "page": "oathra-guide" }, + { + "route": "/products/noa/contact/", + "lang": "ja", + "page": "noa-contact" + }, + { + "route": "/products/noa/sponsor/", + "lang": "ja", + "page": "noa-sponsor" + }, + { + "route": "/products/noa/games/", + "lang": "ja", + "page": "noa-games" + }, { "route": "/en/products/genie/demos/", "lang": "en", @@ -168,5 +183,20 @@ "route": "/en/products/oathra/guide/", "lang": "en", "page": "oathra-guide" + }, + { + "route": "/en/products/noa/contact/", + "lang": "en", + "page": "noa-contact" + }, + { + "route": "/en/products/noa/sponsor/", + "lang": "en", + "page": "noa-sponsor" + }, + { + "route": "/en/products/noa/games/", + "lang": "en", + "page": "noa-games" } ] diff --git a/public/assets/inquiry-context.mjs b/public/assets/inquiry-context.mjs index 121bf24..72c7e2e 100644 --- a/public/assets/inquiry-context.mjs +++ b/public/assets/inquiry-context.mjs @@ -10,8 +10,14 @@ const services = Object.freeze({ // Visitors who want to use a product themselves, rather than commission work. const uses = Object.freeze({launchloom:['Launchloom','launchloom']}); +// 星藍ノア's own request pages (/products/noa/contact|sponsor|games/). The intake has no Noa category, so they file as custom. +const requests = Object.freeze({ + 'noa-contact': [['星藍ノアへのお問い合わせ','星藍ノアへのお問い合わせです。\n\n内容(コラボ・出演・取材など):\n希望の時期(任意):'],['Contacting 星藍ノア (Noa)','This is an inquiry for 星藍ノア (Noa).\n\nTopic (collaboration, appearance, press, etc.):\nPreferred timing (optional):']], + 'noa-sponsor': [['星藍ノアの配信への広告掲載','星藍ノアの配信への広告掲載を相談したいです。\n\n会社・サービス名:\n掲載したい内容:\n希望の期間・時期:'],['Advertising on 星藍ノア (Noa)’s stream','I would like to discuss advertising on 星藍ノア (Noa)’s stream.\n\nCompany or service:\nWhat to show:\nPreferred period:']], + 'noa-game': [['星藍ノアにやって欲しいゲーム','星藍ノアにやって欲しいゲームのリクエストです。\n\nゲーム名:\n機種(PC・Switchなど):\nおすすめの理由(任意):'],['A game for 星藍ノア (Noa) to play','A game request for 星藍ノア (Noa).\n\nGame title:\nPlatform (PC, Switch, etc.):\nWhy you recommend it (optional):']], +}); -export const inquiryContextIds = Object.freeze([...Object.keys(products).map(id=>`product-${id}`), ...Object.keys(services).map(id=>`service-${id}`), ...Object.keys(uses).map(id=>`use-${id}`)]); +export const inquiryContextIds = Object.freeze([...Object.keys(products).map(id=>`product-${id}`), ...Object.keys(services).map(id=>`service-${id}`), ...Object.keys(uses).map(id=>`use-${id}`), ...Object.keys(requests).map(id=>`use-${id}`)]); export function inquiryContext(hash = '', search = '', language = 'ja') { if (!['ja','en'].includes(language)) throw new TypeError('Unsupported inquiry language'); @@ -25,6 +31,10 @@ export function inquiryContext(hash = '', search = '', language = 'ja') { const [name, useCase] = products[id]; return {id:`product-${id}`, useCase, label:ja?`${name}を見ての開発相談`:`A project inspired by ${name}`, message:ja?`${name}の技術を活かした開発について相談したいです。\n\n対象の業務・試したいこと:\n現在の状況:`:`I would like to discuss a project using the technology behind ${name}.\n\nWorkflow or experience to try:\nCurrent situation:`}; } + if (kind === 'use' && Object.hasOwn(requests, id)) { + const [label, message] = requests[id][ja ? 0 : 1]; + return {id:`use-${id}`, useCase:'custom', label, message}; + } if (kind === 'use' && Object.hasOwn(uses, id)) { const [name, useCase] = uses[id]; return {id:`use-${id}`, useCase, label:ja?`${name}を自分の製品で使いたい`:`Using ${name} for my product`, message:ja?`${name}を自分の製品で使いたいです。\n\n紹介したい製品:\n欲しい素材(横動画・縦動画・LP・投稿案):`:`I would like to use ${name} for my own product.\n\nProduct to introduce:\nAssets I need (landscape film, vertical film, landing page, posts):`}; diff --git a/public/assets/owned-guides.css b/public/assets/owned-guides.css index a9ef4db..cd161ad 100644 --- a/public/assets/owned-guides.css +++ b/public/assets/owned-guides.css @@ -1 +1 @@ -:root{color-scheme:light;--ink:#24211e;--paper:#faf8f4;--line:#dedbd4;--muted:#6c665d}*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:2rem}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.8 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:inherit;text-underline-offset:.25em}a:hover{text-decoration-thickness:2px}a:focus-visible,video:focus-visible{outline:3px solid #3651c9;outline-offset:5px}header,footer{max-width:1200px;margin:auto;padding:26px 40px;display:flex;gap:24px;align-items:center;justify-content:space-between}header>a{font-size:23px;font-weight:750;letter-spacing:-1px;text-decoration:none}nav{display:flex;gap:22px;flex-wrap:wrap}main{max-width:960px;padding:64px 40px;margin:auto}h1{font-size:clamp(2.2rem,5vw,4rem);line-height:1.22;letter-spacing:-.04em;max-width:850px;margin:16px 0 28px}h2{font-size:clamp(1.45rem,3vw,2.1rem);line-height:1.4;letter-spacing:-.025em}.lead{font-size:1.2rem;max-width:760px;color:var(--muted)}.eyebrow{font-size:12px;letter-spacing:.15em;font-weight:700;color:var(--muted)}.chapters{margin:38px 0 12px}.chapters a{font-size:14px}section{padding:44px 0;border-bottom:1px solid var(--line)}video{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:contain;background:#141414;border-radius:12px;margin:22px 0;box-shadow:0 12px 40px #24211e0b}.note{font-size:.86rem;color:var(--muted);max-width:800px}.button{display:inline-flex;align-items:center;min-height:48px;border-radius:8px;background:var(--ink);color:#fff;padding:10px 18px;text-decoration:none;margin:8px 0}li{margin:.6rem 0;overflow-wrap:anywhere}footer{border-top:1px solid var(--line);font-size:14px;flex-wrap:wrap}small{color:var(--muted)}.skip-link{position:absolute;left:16px;top:-100px;background:#fff;padding:12px;z-index:10}.skip-link:focus{top:10px}@media(max-width:600px){header,footer{padding:22px 20px;gap:16px}main{padding:32px 20px}header nav{font-size:14px;gap:14px}section{padding:32px 0}.chapters{flex-direction:column;gap:12px}.button{max-width:100%}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}} +:root{color-scheme:light;--ink:#24211e;--paper:#faf8f4;--line:#dedbd4;--muted:#6c665d}*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:2rem}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.8 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:inherit;text-underline-offset:.25em}a:hover{text-decoration-thickness:2px}a:focus-visible,video:focus-visible{outline:3px solid #3651c9;outline-offset:5px}header,footer{max-width:1200px;margin:auto;padding:26px 40px;display:flex;gap:24px;align-items:center;justify-content:space-between}header>a{font-size:23px;font-weight:750;letter-spacing:-1px;text-decoration:none}nav{display:flex;gap:22px;flex-wrap:wrap}main{max-width:960px;padding:64px 40px;margin:auto}h1{font-size:clamp(2.2rem,5vw,4rem);line-height:1.22;letter-spacing:-.04em;max-width:850px;margin:16px 0 28px;word-break:auto-phrase;text-wrap:balance}h2{font-size:clamp(1.45rem,3vw,2.1rem);line-height:1.4;letter-spacing:-.025em}.lead{font-size:1.2rem;max-width:760px;color:var(--muted)}.eyebrow{font-size:12px;letter-spacing:.15em;font-weight:700;color:var(--muted)}.chapters{margin:38px 0 12px}.chapters a{font-size:14px}section{padding:44px 0;border-bottom:1px solid var(--line)}video{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:contain;background:#141414;border-radius:12px;margin:22px 0;box-shadow:0 12px 40px #24211e0b}.note{font-size:.86rem;color:var(--muted);max-width:800px}.button{display:inline-flex;align-items:center;min-height:48px;border-radius:8px;background:var(--ink);color:#fff;padding:10px 18px;text-decoration:none;margin:8px 0}li{margin:.6rem 0;overflow-wrap:anywhere}footer{border-top:1px solid var(--line);font-size:14px;flex-wrap:wrap}small{color:var(--muted)}.skip-link{position:absolute;left:16px;top:-100px;background:#fff;padding:12px;z-index:10}.skip-link:focus{top:10px}@media(max-width:600px){header,footer{padding:22px 20px;gap:16px}main{padding:32px 20px}header nav{font-size:14px;gap:14px}section{padding:32px 0}.chapters{flex-direction:column;gap:12px}.button{max-width:100%}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}} diff --git a/scripts/ui-capture.mjs b/scripts/ui-capture.mjs index 3555b70..9c6c4d4 100644 --- a/scripts/ui-capture.mjs +++ b/scripts/ui-capture.mjs @@ -166,6 +166,10 @@ export const SHOTS = Object.freeze([ // Product long-form sections folded behind one summary row, closed and opened. { file: 'product-fold-mobile.png', route: '/products/launchloom/', width: 390, height: 844, scrollTo: '.rd-more', state: 'phone, Launchloom folded detail row, reduced motion, real time', reducedMotion: true, realtime: true }, { file: 'product-fold-open.png', route: '/products/launchloom/', width: 1440, height: 1000, openDetails: '.rd-more', scrollTo: '.rd-more', state: 'Launchloom detail row opened by a click, reduced motion, real time', reducedMotion: true, realtime: true }, + { file: 'product-consult-noa-desktop.png', route: '/products/noa/', width: 1440, height: 1000, scrollTo: '#consult', state: 'Noa consult band with the three request pages, reduced motion, real time', reducedMotion: true, realtime: true }, + { file: 'product-consult-noa-mobile.png', route: '/products/noa/', width: 390, height: 844, scrollTo: '#consult', state: 'phone, Noa consult band with the three request pages, reduced motion, real time', reducedMotion: true, realtime: true }, + { file: 'noa-sponsor-ja-mobile.png', route: '/products/noa/sponsor/', width: 390, height: 844, state: 'phone, Noa advertising page, reduced motion, real time', reducedMotion: true, realtime: true, fullPage: true }, + { file: 'noa-games-en-desktop.png', route: '/en/products/noa/games/', width: 1440, height: 1000, state: 'Noa game request page, English, reduced motion, real time', reducedMotion: true, realtime: true, fullPage: true }, { file: 'product-consult-launchloom-mobile.png', route: '/products/launchloom/', width: 390, height: 844, scrollTo: '#consult', state: 'phone, Launchloom consult band with the use-it entry, reduced motion, real time', reducedMotion: true, realtime: true }, { file: 'product-launchloom-top.png', route: '/products/launchloom/', width: 1440, height: 1000, state: 'Launchloom hero: try link + film link, reduced motion, real time', reducedMotion: true, realtime: true }, { file: 'product-launchloom-top-mobile.png', route: '/products/launchloom/', width: 390, height: 844, state: 'phone, Launchloom hero: try link + film link, reduced motion, real time', reducedMotion: true, realtime: true }, diff --git a/src/inquiry-page.mjs b/src/inquiry-page.mjs index 852546c..0e9e921 100644 --- a/src/inquiry-page.mjs +++ b/src/inquiry-page.mjs @@ -7,7 +7,7 @@ export function renderInquiry(language) { const t=(a,b)=>ja?a:b; return `

LET’S MAKE SOMETHING

${t('どんな仕事を、変えたいですか。','What would you like to change?')}

${t('まだ仕様がなくても大丈夫です。課題や試してみたいことから、相談してください。','Start with the problem or an experience you would like to try. A finished specification is not required.')}

-
${inquiryContextIds.map(id=>``).join('')}
${t('ご相談の内容','Inquiry details')}

${t('送信ボタンを押すまで、入力内容は送信しません。受付後は運営者が対応のために確認します。自動返信メールは送信されません。','Your input is not transmitted until you press Send. The operator reviews accepted inquiries for follow-up. No automatic confirmation email is sent.')} ${t('情報の取り扱い','Privacy information')}

${t('受付への接続を確認しています。','Checking the inquiry connection.')}

`; +
${inquiryContextIds.map(id=>``).join('')}
${t('ご相談の内容','Inquiry details')}

${t('送信ボタンを押すまで、入力内容は送信しません。受付後は運営者が対応のために確認します。自動返信メールは送信されません。','Your input is not transmitted until you press Send. The operator reviews accepted inquiries for follow-up. No automatic confirmation email is sent.')} ${t('情報の取り扱い','Privacy information')}

${t('受付への接続を確認しています。','Checking the inquiry connection.')}

`; } export async function writeInquiryPages(dist) { for (const language of ['ja','en']) { diff --git a/src/noa-requests.mjs b/src/noa-requests.mjs new file mode 100644 index 0000000..ed9891e --- /dev/null +++ b/src/noa-requests.mjs @@ -0,0 +1,42 @@ +/** 星藍ノア's request pages: contact, stream advertising and game requests. Every form is the site's one inquiry form. */ +import { products } from './products.mjs'; +const escape = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const list = items => ``; + +export function noaRequestPages(lang) { + const ja = lang === 'ja', home = ja ? '/' : '/en/', page = suffix => `${home}products/noa/${suffix}/`; + const noa = products.find(p => p.id === 'noa'); + const facts = noa?.requests?.[lang]; + if (!facts?.sponsorSlot?.length || !facts?.games?.length) throw new Error('Noa request pages need ledger facts'); + const form = (anchor, label) => `${escape(label)}`; + const contact = ja ? ` +

コラボ・出演・取材など、星藍ノアへのご連絡はこちらから。

+

ノアはAIで動く配信キャラクターです。運営はReachmade Labで、お返事は運営者がメールでお送りします。

+

内容に合わせて選んでください

+
+

そのほかのお問い合わせ

最初のご連絡に、個人情報や機密情報は必要ありません。

${form('noa-contact','ノアに問い合わせる')}
` : ` +

Collaborations, appearances, press and more: contact 星藍ノア (Noa) here.

+

Noa is an AI-driven streaming character run by Reachmade Lab. The operator replies by email.

+

Choose what fits

+
+

Anything else

Personal or confidential information is not needed for a first message.

${form('noa-contact','Contact Noa')}
`; + const sponsor = ja ? ` +

ノアの配信画面に、企業・サービスの広告を掲載できます。

+

掲載のしかた

${list(facts.sponsorSlot)}
+

料金・期間

ご相談のうえで決めます。内容によっては、お受けできないことがあります。

${form('noa-sponsor','広告掲載を相談する')}
` : ` +

Companies and services can advertise on Noa’s stream.

+

How it appears

${list(facts.sponsorSlot)}
+

Price and period

Agreed case by case. Some requests may be declined.

${form('noa-sponsor','Discuss advertising')}
`; + const games = ja ? ` +

ノアに遊んで欲しいゲームを、教えてください。

+

今あそんでいるゲーム

${list(facts.games)}
+

リクエストについて

${form('noa-game','ゲームをリクエストする')}
` : ` +

Tell us a game you would like Noa to play.

+

Playing now

${list(facts.games)}
+

About requests

${form('noa-game','Request a game')}
`; + return [ + {product:'noa', suffix:'contact', title:ja?'星藍ノアへのお問い合わせ':'Contact 星藍ノア (Noa)', description:ja?'星藍ノアへのコラボ・出演・取材などのお問い合わせ窓口。':'Contact 星藍ノア (Noa) about collaborations, appearances and press.', eyebrow:'星藍ノア / CONTACT', body:contact}, + {product:'noa', suffix:'sponsor', title:ja?'配信への広告掲載を募集しています':'Advertise on Noa’s stream', description:ja?'星藍ノアの配信画面に、企業・サービスの広告を掲載できます。':'Companies and services can advertise on 星藍ノア (Noa)’s stream.', eyebrow:'星藍ノア / ADVERTISING', body:sponsor}, + {product:'noa', suffix:'games', title:ja?'やって欲しいゲームを募集中':'Request a game for Noa', description:ja?'星藍ノアに遊んで欲しいゲームのリクエストを受け付けています。':'Suggest a game for 星藍ノア (Noa) to play on stream.', eyebrow:'星藍ノア / GAME REQUESTS', body:games}, + ]; +} diff --git a/src/owned-guides.mjs b/src/owned-guides.mjs index 94555f9..b0ea88d 100644 --- a/src/owned-guides.mjs +++ b/src/owned-guides.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { oathraReadinessGuide } from './oathra-readiness-guide.mjs'; +import { noaRequestPages } from './noa-requests.mjs'; const escape = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const films = [ ['prototype', 'genie-orbit-web.mp4', '小さなHTMLを作る', 'Build a small HTML prototype'], @@ -11,7 +12,7 @@ const films = [ function frame(lang, product, suffix, title, body, metadata = {}) { const ja=lang==='ja', home=ja?'/':'/en/', route=`${home}products/${product}/${suffix}/`; const other=`${ja?'/en/':'/'}products/${product}/${suffix}/`; - return `${escape(title)} | Reachmade
Reachmade

${escape(metadata.eyebrow || (product==='genie'?'GENIE / REAL WORKFLOWS':'AI MEETING / GET STARTED'))}

${escape(title)}

${body}
\n`; + return `${escape(title)} | Reachmade
Reachmade

${escape(metadata.eyebrow || (product==='genie'?'GENIE / REAL WORKFLOWS':'AI MEETING / GET STARTED'))}

${escape(title)}

${body}
\n`; } export async function writeOwnedGuides(dist) { const manifest=JSON.parse(await fs.readFile(path.join(dist,'media/originals/manifest.json'),'utf8')); @@ -36,7 +37,7 @@ export async function writeOwnedGuides(dist) { if(!has(meetingVideo))throw new Error('Missing original AI Meeting demo'); const meeting=`

${ja?'タスクを試す入口と、AIと音声で話す入口は別です。用途に合わせて選べます。':'Trying saved tasks and speaking with the AI are separate entry points. Choose the path you need.'}

${ja?'まず、タスクを1件残す':'Save one task first'}

${ja?'文字入力のタスク機能は登録不要です。追加・完了・延期を試し、ページを開き直して保存状態を確認してください。保存先はこのブラウザーです。':'The text task flow needs no account. Add, complete or defer a task, then reopen the page to check the saved state. This path stores tasks in this browser.'}

${ja?'登録なしでタスクを試す':'Try tasks without an account'} ↗

${ja?'音声で会話する':'Speak with the AI'}

${ja?'ホスト版の音声体験にはメール確認と利用時間・共有容量の条件があります。最新の利用可能状態はアプリで確認してください。タスク体験と同じ条件ではありません。':'The hosted voice experience requires verified email and is subject to time and shared-capacity limits. Check current availability in the app. It has different conditions from the text task flow.'}

${ja?'音声アプリを開く':'Open the voice app'} ↗

${ja?'アプリは従来の公開先で動作します。認証・保存データの移行は行っていません。':'The application keeps its existing hosting origin. Authentication and stored data have not been migrated.'}

${ja?'会話から保存までの実演':'A recorded voice-to-task workflow'}

${ja?'収録用の合成日本語入力、実Gemini応答、一部スクリプトによる提案確認を含む録画です。人による連続利用や、別のMeet・Zoom参加者への送受信を実証するものではありません。':'Recorded with synthetic Japanese input, real Gemini responses and some scripted proposal confirmations. It does not prove sustained human use or audio/video delivery to a separate Meet or Zoom participant.'}

${ja?'収録方法と検証範囲':'Recording method and validation scope'} ↗

${ja?'ローカル環境で試す':'Run it locally'}

${ja?'アバターと音声再生のプレビュー、AIとの会話、外部会議への接続では、必要な設定が異なります。':'Avatar/audio playback, AI conversation and external meeting connections require different setup.'}

${ja?'READMEと導入手順':'README and installation'} ↗
`; const guide = oathraReadinessGuide(lang); - for(const [product,suffix,title,body,metadata] of [['genie','demos',ja?'使える結果まで、実演で見る。':'See the work reach a usable result.',genie],['ai-meeting','guide',ja?'話す前に、試し方を選ぶ。':'Choose your first experience.',meeting], [guide.product,guide.suffix,guide.title,guide.body,guide]]) { + for(const [product,suffix,title,body,metadata] of [['genie','demos',ja?'使える結果まで、実演で見る。':'See the work reach a usable result.',genie],['ai-meeting','guide',ja?'話す前に、試し方を選ぶ。':'Choose your first experience.',meeting], [guide.product,guide.suffix,guide.title,guide.body,guide], ...noaRequestPages(lang).map(p=>[p.product,p.suffix,p.title,p.body,p])]) { const route=`${home}products/${product}/${suffix}/`,target=path.join(dist,route,'index.html'); await fs.mkdir(path.dirname(target),{recursive:true});await fs.writeFile(target,frame(lang,product,suffix,title,body,metadata)); routes.push({route,lang,page:`${product}-${suffix}`}); diff --git a/src/products.mjs b/src/products.mjs index 270f2d2..e7f0b48 100644 --- a/src/products.mjs +++ b/src/products.mjs @@ -305,6 +305,14 @@ export const products = [ // Static implementation evidence; not a gameplay-skill or live-stream performance measurement. // Public video destinations checked 2026-10-04; implementation-check date is unchanged. demo: noaVideos.gameplay, capabilitiesCheckedAt: '2026-10-02', + // 2026-10-21: sponsor slot checked in Jev_VTuber_Studio origin/main docs/SPONSOR_OVERLAY.md (870167e). + // Still images and text only: video ads exist there but OBS playback is unverified. No audience figures or prices. + // Games: only the title the F-01 card already names; Minecraft and Smash are not checked for the ledger. + requests: { + checkedAt: '2026-10-21', + ja: {sponsorSlot: ['配信画面の広告枠(16:9)に、画像またはテキストで表示します。画像はPNG・JPEG・WebP、1920×1080pxを推奨します。', '広告には必ず「広告 / PR」と表示し、YouTubeの有料プロモーションとして申告します。', '画面上のURLは押せないため、リンクは配信の説明欄にも載せます。'], games: ['Vampire Survivors(ノアが自分で操作)']}, + en: {sponsorSlot: ['Shown in the 16:9 ad slot on the stream, as an image or text. Images: PNG, JPEG or WebP, 1920×1080 px recommended.', 'Every ad carries the on-screen label “広告 / PR” (Ad / PR) and is declared as a paid promotion on YouTube.', 'Links on screen cannot be clicked, so the link also goes in the stream description.'], games: ['Vampire Survivors (Noa plays it herself)']} + }, ja: { headline: 'コメントを、声と表情に。', demoLabel: 'ゲームのテスト収録を見る(編集版)', evidenceLabel: '返答・声・表情の解説を見る', previewLabel: '紹介映像の一場面(画面は再現)', short: 'コメントに、声と表情で返す配信キャラクター。', diff --git a/src/redesign-copy.mjs b/src/redesign-copy.mjs index a77a930..319852c 100644 --- a/src/redesign-copy.mjs +++ b/src/redesign-copy.mjs @@ -36,7 +36,7 @@ export const redesignCopy = { scopeNote: () => '最新の利用条件は、各リポジトリでご確認ください。', scopeLabel: 'SCOPE / 範囲', proofLabel: 'PROOF / 公開している根拠', linkKinds: { demo: 'DEMO', source: 'SOURCE', evidence: 'EVIDENCE' }, sourceLabel: 'コードをGitHubで読む', evidenceLabel: '検証資料を読む', - consultKicker: '(07) この技術で相談する', consultTitle: 'この仕組みを、あなたの仕事にも。', consultCta: '開発を相談する', useLaunchloom: '自分の製品で使いたい', + consultKicker: '(07) この技術で相談する', consultTitle: 'この仕組みを、あなたの仕事にも。', consultCta: '開発を相談する', useLaunchloom: '自分の製品で使いたい', noaRequests: [['contact', 'ノアに問い合わせる'], ['sponsor', '広告掲載の募集'], ['games', 'やって欲しいゲームを募集']], next: 'NEXT', fig: 'FIG.', filmKinds: { recording: '実録画(無音・編集あり)', @@ -80,7 +80,7 @@ export const redesignCopy = { scopeNote: () => 'Current terms live in each repository.', scopeLabel: 'SCOPE', proofLabel: 'PROOF / PUBLISHED EVIDENCE', linkKinds: { demo: 'DEMO', source: 'SOURCE', evidence: 'EVIDENCE' }, sourceLabel: 'Read the code on GitHub', evidenceLabel: 'Read the evaluation record', - consultKicker: '(07) Build with it', consultTitle: 'Bring this to your work.', consultCta: 'Discuss a project', useLaunchloom: 'Use it for my product', + consultKicker: '(07) Build with it', consultTitle: 'Bring this to your work.', consultCta: 'Discuss a project', useLaunchloom: 'Use it for my product', noaRequests: [['contact', 'Contact Noa'], ['sponsor', 'Advertise on the stream'], ['games', 'Request a game']], next: 'NEXT', fig: 'FIG.', filmKinds: { recording: 'Real recording (silent · edited)', diff --git a/src/redesign.mjs b/src/redesign.mjs index 2159d0b..09a3793 100644 --- a/src/redesign.mjs +++ b/src/redesign.mjs @@ -343,7 +343,7 @@ ${visionKept}
${esc(r.moreSummary)}${esc(r.moreHint)}${showHighlights ? `

${esc(r.highlights)}

${highlights}
` : ''}

${esc(r.consultKicker)}

${esc(t.consult)}

${moreKept ? `
${moreKept}
` : ''}

${esc(r.features)}

${esc(t.short)}

${source ? source : `

${esc(r.featuresNote(capabilitiesCheckedAt))}

`}
${features}

${esc(r.scopeKicker)}

${r.scopeTitle}

${esc(r.scopeNote(sourcesCheckedAt))}

${esc(r.scopeLabel)}

${esc(t.scope)}

${esc(r.proofLabel)}

${esc(t.proof)}

-

${esc(r.consultKicker)}

${esc(r.consultTitle)}

${p.id === 'launchloom' ? `${esc(r.useLaunchloom)}` : ''}${esc(r.consultCta)}
+

${esc(r.consultKicker)}

${esc(r.consultTitle)}

${p.id === 'noa' ? r.noaRequests.map(([suffix, label]) => `${esc(label)}`).join('') : ''}${p.id === 'launchloom' ? `${esc(r.useLaunchloom)}` : ''}${esc(r.consultCta)}
`; } diff --git a/src/site-experience.mjs b/src/site-experience.mjs index fd4d53c..36883d6 100644 --- a/src/site-experience.mjs +++ b/src/site-experience.mjs @@ -20,8 +20,8 @@ export function improveCopy(original) { selectedTitle: 'Choose from the real work.', selectedText: 'Representative products are shown with recorded interfaces. Capabilities, access requirements and remaining limitations stay separate.', }); - copy.ja.privacySections[0] = ['問い合わせの送信・保存', '問い合わせフォームの送信ボタンを押すと、お名前、返信先メール、任意の組織名、相談の対象・内容、同意、再送判定用ID、表示言語を同じドメインの受付経路に送信します。運営者の既存Google Cloud受付システムに保存し、問い合わせ対応に使います。入力内容をAIや広告・解析サービスへ送りません。受付番号は保存完了後に表示し、自動返信メールは送りません。受付側の保存期限設定は90日です。期限後の削除処理は非同期のため、正確な削除時刻を保証するものではありません。']; - copy.en.privacySections[0] = ['Sending and storing an inquiry', 'Pressing Send transmits your name, reply email, optional organization, topic, message, consent, retry ID and language through the same-origin intake route to the operator’s existing Google Cloud inquiry system for follow-up. Form input is not sent to an AI, advertising or analytics service. A receipt is shown only after storage succeeds; no automatic email is sent. The intake retention setting is 90 days. Expired records are removed asynchronously, not at a guaranteed exact time.']; + copy.ja.privacySections[0] = ['問い合わせの送信・保存', '問い合わせフォームの送信ボタンを押すと、お名前、返信先メール、任意の組織名、相談の対象・内容、同意、再送判定用ID、表示言語を同じドメインの受付経路に送信します。運営者の既存Google Cloud受付システムに保存し、問い合わせ対応に使います。保存後、同じ内容をメール配信サービスResend(米国)経由で運営者のメールアドレスへ1通送り、運営者はそのメールから返信します。入力内容をAIや広告・解析サービスへは送りません。受付番号は保存完了後に表示し、自動返信メールは送りません。受付側の保存期限設定は90日です。期限後の削除処理は非同期のため、正確な削除時刻を保証するものではありません。']; + copy.en.privacySections[0] = ['Sending and storing an inquiry', 'Pressing Send transmits your name, reply email, optional organization, topic, message, consent, retry ID and language through the same-origin intake route to the operator’s existing Google Cloud inquiry system for follow-up. Once stored, the same content is emailed once to the operator through the email delivery service Resend (United States), and the operator replies from that email. Form input is not sent to an AI, advertising or analytics service. A receipt is shown only after storage succeeds; no automatic email is sent. The intake retention setting is 90 days. Expired records are removed asynchronously, not at a guaranteed exact time.']; copy.ja.privacySections[1][1] = 'このサイト内の問い合わせは、画面に表示する運営者の受付へ送信します。製品サイト・GitHubへのリンクには外部サービスが含まれます。移動後は、その送信先と情報の取り扱いを確認してください。'; copy.en.privacySections[1][1] = 'On-site inquiries use the operator’s intake described beside the form. Some product links and GitHub open external services. Check their destination and privacy information before submitting there.'; return copy; diff --git a/tests/conversion-path.test.mjs b/tests/conversion-path.test.mjs index 59fcff3..e0999e2 100644 --- a/tests/conversion-path.test.mjs +++ b/tests/conversion-path.test.mjs @@ -32,6 +32,17 @@ for (const lang of ['ja','en']) { assert.equal(inquiryContext('#product-noa','',lang).useCase,'custom'); for(const id of ['voice-ai','ai-character'])assert.equal(inquiryContext(`#service-${id}`,'',lang).useCase,'custom'); }); + test(`${lang}: each Noa request page lands on its own form context and a valid inquiry`,async()=>{ + const labels=new Set(); + for(const [suffix,anchor] of [['contact','noa-contact'],['sponsor','noa-sponsor'],['games','noa-game']]){ + assert.ok((await read(`${b}products/noa/${suffix}/index.html`)).includes(`href="/${b}contact/#use-${anchor}"`)); + const ctx=inquiryContext(`#use-${anchor}`,'',lang); + assert.equal(ctx.useCase,'custom');assert.match(ctx.label,/ノア/);labels.add(ctx.label); + assert.ok(canInsertInquiryTemplate({message:'',useCase:ctx.useCase,busy:false,unresolved:false,accepted:false})); + assert.doesNotThrow(()=>parseInquiry({...valid,language:lang,useCase:ctx.useCase,message:ctx.message})); + } + assert.equal(labels.size,3); + }); } test('unknown or hostile URL input never becomes a context or message',()=>{ for(const [hash,search] of [['#service-constructor',''],['#product-__proto__',''],['','?product=%3Cscript%3E'],['#service-voice-ai%0Asecret',''],['#product-genie/../../',''],['','?message=private&email=private'],['#service-toString','']])assert.equal(inquiryContext(hash,search),null); diff --git a/tests/owned-sites.test.mjs b/tests/owned-sites.test.mjs index 98b9f00..4c2aaec 100644 --- a/tests/owned-sites.test.mjs +++ b/tests/owned-sites.test.mjs @@ -28,17 +28,19 @@ test('owned recordings load offline and reject missing or tampered originals',as await fs.rm(file);await assert.rejects(readOwnedRecording('https://reachmade.com'+route,{repositoryRoot})); }finally{await fs.rm(repositoryRoot,{recursive:true,force:true});} }); -test('six bilingual support pages use correct media, guide and app links',async()=>{ +test('twelve bilingual support pages use correct media, guide and app links',async()=>{ const dist=await fs.mkdtemp(path.join(os.tmpdir(),'owned-guides-')); try{ const files=['genie/assets/genie-orbit-web.mp4','genie/assets/genie-web-proposal-web.mp4','genie/assets/genie-product-film-web.mp4','ai-meeting/demo.mp4'].map(p=>({path:'/media/originals/'+p,duration:33})); await fs.mkdir(path.join(dist,'media/originals'),{recursive:true});await fs.writeFile(path.join(dist,'media/originals/manifest.json'),JSON.stringify({files})); - const routes=await writeOwnedGuides(dist);assert.equal(routes.length,6); + const routes=await writeOwnedGuides(dist);assert.equal(routes.length,12); for(const {route,lang} of routes){ const html=await fs.readFile(path.join(dist,route,'index.html'),'utf8'); assert.ok(html.includes(`lang="${lang}"`));assert.ok(html.includes(`href="https://reachmade.com${route}"`)); assert.ok(!html.includes('chatgpt.site')); - if (!route.includes('oathra')) assert.ok(html.includes('preload="none"')); + const noa=/products\/noa\/(contact|sponsor|games)\/$/.exec(route); + if (noa) { assert.ok(html.includes(`/contact/#use-noa-${noa[1]==='games'?'game':noa[1]}"`)); assert.doesNotMatch(html, /{ diff --git a/tests/site.test.mjs b/tests/site.test.mjs index 106e868..00ea478 100644 --- a/tests/site.test.mjs +++ b/tests/site.test.mjs @@ -30,8 +30,8 @@ test('contact must use a known mode',()=>{const c=clone();c.contact.mode='form'; test('rejects non-HTTPS contact',()=>{const c=clone();c.contact.url='javascript:alert(1)';assert.throws(()=>validateConfig(c));}); test('email mode rejects an absent address',()=>{const c=clone();c.contact.email=null;assert.throws(()=>{c.contact.mode='email';validateConfig(c);});}); test('email mode supports an owner-configured address',()=>{const c=clone();c.contact.mode='email';c.contact.email='owner@example.org';assert.doesNotThrow(()=>validateConfig(c));}); -test('34 routes represent portfolio, product and owned guide pages in both languages',()=>{ - assert.equal(routes.length,34);for(const lang of ['ja','en'])assert.equal(routes.filter(r=>r.lang===lang).length,17); +test('40 routes represent portfolio, product and owned guide pages in both languages',()=>{ + assert.equal(routes.length,40);for(const lang of ['ja','en'])assert.equal(routes.filter(r=>r.lang===lang).length,20); assert.equal(routes.filter(r=>r.page.startsWith('product-')).length,14); }); test('attribute checks do not mistake lazy data-src attributes for active sources',()=>{ @@ -56,7 +56,7 @@ for(const r of routes){ assert.match(html,/hreflang="ja"/);assert.match(html,/hreflang="en"/);assert.match(html,/hreflang="x-default"/); assert.match(html,/og:image/);assert.match(html,/name="description"/); const ids=attr(html,'id');assert.equal(new Set(ids).size,ids.length,'duplicate IDs'); - assert.equal((html.match(/