From d490d66558a8dc53598c30c627d70783502809f3 Mon Sep 17 00:00:00 2001 From: FORIFOR Date: Mon, 19 Oct 2026 02:12:36 +0900 Subject: [PATCH] feat: English Genie and Oathra pages follow the Japanese composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - genie-lp and oathra-lp now compose both /products// and /en/products// from one structure with a per-language copy table; the Japanese output is unchanged except the Genie demo note now names the renamed 「動かしてみる」 link. - The 30-second Genie and 15-second Oathra films appear on the English pages too, labelled as carrying Japanese on-screen text / audio. - English Oathra explains the three concept demos in English and says their UI and subtitles are Japanese; the saved Japanese Markdown on the English Genie page is marked lang="ja". - Tests and browser suites that pinned "English is untouched" now check the English composition instead. Co-Authored-By: Claude Opus 5.5 --- scripts/ui-capture.mjs | 6 + scripts/verify-outcome-first.py | 8 +- scripts/verify_showcase.py | 8 +- src/fifteen-second-films.mjs | 49 ++++-- src/genie-lp.mjs | 220 +++++++++++++++++++-------- src/oathra-lp.mjs | 195 +++++++++++++++++++----- tests/fifteen-second-films.test.mjs | 21 +-- tests/genie-lp.test.mjs | 14 +- tests/oathra-concept-demos.test.mjs | 9 +- tests/oathra-lp.test.mjs | 16 +- tests/product-art-direction.test.mjs | 8 +- tests/product-landings.test.mjs | 12 +- tests/showcase.test.mjs | 4 +- 13 files changed, 422 insertions(+), 148 deletions(-) diff --git a/scripts/ui-capture.mjs b/scripts/ui-capture.mjs index 8ed292b..a204183 100644 --- a/scripts/ui-capture.mjs +++ b/scripts/ui-capture.mjs @@ -169,6 +169,12 @@ export const SHOTS = Object.freeze([ { 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 }, + // 2026-10-19: English Genie and Oathra follow the Japanese composition. + { file: 'genie-en-demos.png', route: '/en/products/genie/', width: 1440, height: 1000, openDetails: '.rd-kept--fold', scrollTo: '#demos', state: 'English Genie 02 requests, reduced motion, real time', reducedMotion: true, realtime: true }, + { file: 'genie-en-artifacts-mobile.png', route: '/en/products/genie/', width: 390, height: 1400, openDetails: '.rd-kept--fold', scrollTo: '#artifacts', state: 'phone, English Genie 03 files, reduced motion, real time', reducedMotion: true, realtime: true }, + { file: 'oathra-en-demos.png', route: '/en/products/oathra/', width: 1440, height: 1000, scrollTo: '#concept-demos', state: 'English Oathra concept demos, reduced motion, real time', reducedMotion: true, realtime: true }, + { file: 'oathra-en-ring-mobile.png', route: '/en/products/oathra/', width: 390, height: 1400, scrollTo: '#ring', state: 'phone, English Oathra ring, reduced motion, real time', reducedMotion: true, realtime: true }, + { file: 'oathra-en-status.png', route: '/en/products/oathra/', width: 1440, height: 1000, scrollTo: '#status', state: 'English Oathra where it stands, reduced motion, real time', reducedMotion: true, realtime: true }, { file: 'products-index-noa.png', route: '/products/', width: 1440, height: 1000, scrollTo: '#noa', state: 'product index, Noa card caption, reduced motion, real time', reducedMotion: true, realtime: true }, ]); diff --git a/scripts/verify-outcome-first.py b/scripts/verify-outcome-first.py index 6753dad..c6709af 100644 --- a/scripts/verify-outcome-first.py +++ b/scripts/verify-outcome-first.py @@ -72,8 +72,8 @@ def product_checks(page, lang, product, width): assert video.get_attribute('autoplay') is None assert video.get_attribute('preload') == 'none' assert video.evaluate('v=>v.paused'), 'No product media plays on entry' - if lang == 'ja' and product in ['oathra', 'noa']: - # These authored pages replaced the generic sample CTA before this PR. + if product == 'oathra' or (lang == 'ja' and product == 'noa'): + # These authored pages replaced the generic sample CTA before this PR (English Oathra since 2026-10-19). # Their real film, source and disclosure replace the missing widget check. name = 'olp' if product == 'oathra' else 'nlp' figure = page.locator(f'.{name}-film').first @@ -85,8 +85,8 @@ def product_checks(page, lang, product, width): for asset in film.values(): assert (ROOT / 'dist' / asset.lstrip('/')).is_file(), asset if product == 'oathra': - expect(figure).to_contain_text('設計画面 · 未リリース') - expect(figure.locator('figcaption')).to_contain_text('現行のOathraにはまだありません') + expect(figure).to_contain_text('設計画面 · 未リリース' if lang == 'ja' else 'design screens · not released') + expect(figure.locator('figcaption')).to_contain_text('現行のOathraにはまだありません' if lang == 'ja' else 'the current Oathra does not have them yet') expect(page.locator('#recordings')).to_be_visible() if width == 390: page.locator('.owned-film [data-mode="story"]').click() diff --git a/scripts/verify_showcase.py b/scripts/verify_showcase.py index 8b10b23..525521b 100644 --- a/scripts/verify_showcase.py +++ b/scripts/verify_showcase.py @@ -78,8 +78,9 @@ def copyfile(self, source, outputfile): remaining -= len(chunk) # Product pages whose top film is a produced film rather than the product recording, with the -# sentence its caption must carry. Japanese Genie plays its TaskDock reconstruction (src/genie-lp.mjs, TOP_FILM). -PAGE_FILMS = {('ja', 'genie'): ('/media/films/genie-taskdock-13s.mp4', '実アプリの録画ではありません')} +# sentence its caption must carry. Genie plays its TaskDock reconstruction (src/genie-lp.mjs, TOP_FILM) in both languages. +PAGE_FILMS = {('ja', 'genie'): ('/media/films/genie-taskdock-13s.mp4', '実アプリの録画ではありません'), + ('en', 'genie'): ('/media/films/genie-taskdock-13s.mp4', 'It is not a recording of the real app')} def main(home_only=False, output='film-qa/product-landings'): root = Path(__file__).resolve().parents[1] @@ -190,7 +191,8 @@ def native_noa(page, failed=False): # A produced film on a product page must be the declared file and say what it is. assert info['src'] == origin+PAGE_FILMS[(lang, product)][0], info caption = page.locator(selector+' figcaption').inner_text() - assert PAGE_FILMS[(lang, product)][1] in caption and '実録画' not in page.locator(selector+' .owned-film__bar').inner_text(), caption + bar = page.locator(selector+' .owned-film__bar').inner_text() + assert PAGE_FILMS[(lang, product)][1] in caption and '実録画' not in bar and 'REAL PRODUCT' not in bar.upper(), caption else: assert info['src'].startswith(origin+'/media/products/'), info video.evaluate('(v)=>v.pause()') diff --git a/src/fifteen-second-films.mjs b/src/fifteen-second-films.mjs index b5c103a..986915d 100644 --- a/src/fifteen-second-films.mjs +++ b/src/fifteen-second-films.mjs @@ -1,4 +1,5 @@ -/** Short introduction films (Genie 30 s, Oathra 15 s): one film each on the Japanese Genie and Oathra pages. +/** Short introduction films (Genie 30 s, Oathra 15 s): one film each on the Genie and Oathra pages, in both languages. + * The films carry Japanese on-screen text (and Oathra's call audio is Japanese), so the English placement says so. * (The Japanese home offers the same two films inside its product stage — see home-v4.mjs.) They are edited films with motion graphics, * so every placement says so beside the player, and says which parts are real. Native controls * only: nothing autoplays, nothing downloads before a press, and the page needs no script. */ @@ -24,6 +25,16 @@ export const FILMS = Object.freeze({ kicker: '30秒の紹介映像', title: ['呼び出して、頼んで、', '結果を受け取るまで。'], note: '紹介のために作った映像です。前半は現行製品の操作を再現したイメージで、「ここからは、次のUIの設計です」以降は次のUIの設計プレビューです。設計プレビューの機能はまだ実装されていません。どちらも実アプリの録画ではなく、メモやページの内容は架空の例です。', + en: Object.freeze({ + page: 'en/products/genie/index.html', + label: 'Genie 30-second introduction film', + audio: 'silent', + meta: '30 s · silent · plays only when pressed · Japanese on-screen text · includes a reconstruction and a design preview', + kicker: '30-second introduction film', + title: ['Call it up, ask,', 'and get the result back.'], + note: 'A film made to introduce Genie. The first half is a reconstruction of the current product; from “ここからは、次のUIの設計です” (“from here, the next UI design”) it is a design preview of the next UI, whose features are not built yet. Neither part is a recording of the real app, and the notes and pages shown are fictional examples.', + fallback: 'This video cannot be played in this browser.', + }), }), oathra: Object.freeze({ page: 'products/oathra/index.html', @@ -36,34 +47,52 @@ export const FILMS = Object.freeze({ kicker: '15秒の紹介映像', title: ['一本の電話と、', '確定を決めた一言。'], note: '公開シミュレーター(ホテル・リンゴ)との通話記録から、音声を自然な間で切り出して編集した映像です。再生速度は変えていません。画面の時刻は、この動画の再生位置です。', + en: Object.freeze({ + page: 'en/products/oathra/index.html', + label: 'Oathra 15-second introduction film', + audio: 'with Japanese audio', + meta: '15 s · Japanese audio · plays only when pressed · edited film with staging', + kicker: '15-second introduction film', + title: ['One phone call,', 'and the words that settled it.'], + note: 'Edited from a recorded call with the public simulator (Hotel Ringo), with the audio cut at natural pauses. Playback speed is unchanged. The time on screen is the position in this video.', + fallback: 'This video cannot be played in this browser.', + }), }), }); -const film = id => (Object.hasOwn(FILMS, id) ? FILMS[id] : null); +const film = (id, lang = 'ja') => { + if (!Object.hasOwn(FILMS, id)) return null; + if (lang === 'ja') return {...FILMS[id], fallback: 'この動画は、このブラウザーでは再生できません。'}; + if (lang === 'en') return {...FILMS[id], ...FILMS[id].en}; + throw new TypeError(`Unsupported film language: ${lang}`); +}; -export function filmSection(id) { - const f = film(id); +export function filmSection(id, lang = 'ja') { + const f = film(id, lang); if (!f) throw new Error(`Unknown film: ${id}`); + const paren = lang === 'ja' ? s => `(${s})` : s => ` (${s})`; const heading = `film15-${id}-title`; - return `

${e(f.kicker)}

${f.title.map(e).join('
')}

${e(f.note)}

${e(f.meta)}
`; + return `

${e(f.kicker)}

${f.title.map(e).join('
')}

${e(f.note)}

${e(f.meta)}
`; } // Insert once, before the anchor. Fails closed when the page no longer has the anchor, // and is idempotent when the section is already there. -export function insertFilm(html, id) { - const f = film(id); +export function insertFilm(html, id, lang = 'ja') { + const f = film(id, lang); if (!f) throw new Error(`Unknown film: ${id}`); if (html.includes(`${SECTION}="${id}"`)) return html; const at = html.indexOf(f.before); if (at < 0 || html.indexOf(f.before, at + 1) >= 0) throw new Error(`Film ${id}: expected exactly one insertion point`); - return html.slice(0, at) + filmSection(id) + html.slice(at); + return html.slice(0, at) + filmSection(id, lang) + html.slice(at); } export async function writeFifteenSecondFilms(dist) { for (const [id, f] of Object.entries(FILMS)) { for (const asset of [f.src, f.poster]) await fs.access(path.join(dist, asset)).catch(() => { throw new Error(`Film ${id}: missing ${asset}`); }); - const file = path.join(dist, f.page); - await fs.writeFile(file, insertFilm(await fs.readFile(file, 'utf8'), id)); + for (const [lang, page] of [['ja', f.page], ['en', f.en.page]]) { + const file = path.join(dist, page); + await fs.writeFile(file, insertFilm(await fs.readFile(file, 'utf8'), id, lang)); + } } const assets = path.join(dist, 'assets'); const [css, layer] = await Promise.all(['showcase.css', 'fifteen-second-films.css'].map(f => fs.readFile(path.join(assets, f), 'utf8'))); diff --git a/src/genie-lp.mjs b/src/genie-lp.mjs index 1ea4566..e5fb307 100644 --- a/src/genie-lp.mjs +++ b/src/genie-lp.mjs @@ -1,20 +1,20 @@ /** - * Genie landing page (Japanese product page only): "明るいアトリエ × 青い魔法 × 本物のTaskDock". + * Genie landing page (Japanese and English product pages): "明るいアトリエ × 青い魔法 × 本物のTaskDock". * - * Runs last on /products/genie/ and RE-COMPOSES the existing owned page instead of replacing it, so every - * contract the product page already carries (13-second real recording, ledger features, try-now, action note, + * Runs last on /products/genie/ and /en/products/genie/ and RE-COMPOSES the existing owned page instead of replacing it, + * so every contract the product page already carries (13-second real recording, ledger features, try-now, action note, * scope/proof, next-UI design frames, 15-second film, related products) stays in place: * hero (invoke Genie during work) → 01 ask about the current screen (ledger features + real frames) * → 02 three concrete work requests, each with its request and result cut from the published films * → 03 take the answer as a file (saved Markdown + HTML) * → 15-second film → flow → 04 input/model choices (ledger facts) → start with a note → boundaries … * - * - Every still is cut from the published product film (real app screens, fictional demo data). + * - Every still is cut from the published product film (real app screens, fictional demo data, Japanese UI). * - The top film is the 01 TaskDock reconstruction written out as a video (media/films/genie-taskdock-13s.mp4), labelled as * a reconstruction of the next version in development, never as a recording. Nothing autoplays; preload="none". * - 02 shows stills, not the films (2026-09-30: the films read as unfinished). The films stay on /products/genie/demos/. * - Complete without JavaScript: every request and result is an image with alt text; the HTML artifact opens in a new tab. - * - Claims come from src/products.mjs only. English page is untouched. + * - Claims come from src/products.mjs only. The English page (2026-10-19) follows the Japanese one section by section. */ import fs from 'node:fs/promises'; import path from 'node:path'; @@ -23,7 +23,7 @@ import {products as ledgerProducts} from './products.mjs'; export const GENIE_LP_VERSION = '20261002-genie-lp-5'; const CSS_MARK = '/* REACHMADE_GENIE_LP */'; const JS_MARK = '// REACHMADE_GENIE_LP'; -const PAGE = 'products/genie/index.html'; +const PAGES = Object.freeze({ja: 'products/genie/index.html', en: 'en/products/genie/index.html'}); const e = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const ext = (href, label, cls) => `${label} `; const A = '/assets/products/genie/'; @@ -44,8 +44,12 @@ export const FRAMES = Object.freeze({ // Genie の印(白)。GenieMark-source.png を切り詰めたもの。197×120。 mark: {src: `${A}genie-mark-white.png`, alt: ''} }); -// 01 は同じ作業画面の上で、TaskDock(次の版・1b)だけが姿を変える。 -const HOW = [['01', '呼び出す', 'idle'], ['02', '必要な情報を渡す', 'listen'], ['03', '結果を使う', 'done']]; +const FRAMES_EN = Object.freeze({ + question: 'Typing a request with a screenshot attached', + answer: 'An answer listing three priorities', + orbit: 'An HTML page of orbiting planets, started from a request to Genie and finished with Codex', + work: 'A working dashboard (Pulse)' +}); // Each request is shown as two stills cut from its published film: the request as typed, then the result on screen. const still = (file, width, height, alt) => ({src: `${A}${file}`, width, height, alt}); export const DEMOS = Object.freeze([ @@ -59,68 +63,157 @@ export const DEMOS = Object.freeze([ {id: 'prototype', ask: 'このアイデアを、動く試作品にして', shows: '依頼 → 生成したHTML → 実際に操作できる結果', title: '小さなHTMLを作る', steps: [still('lp-02-prototype-ask.jpg', 1280, 380, '「この落書きを、触れる小さな宇宙にして」と、作り方の条件を添えて入力した依頼欄'), still('lp-02-prototype-result.jpg', 1280, 720, '惑星が公転するHTML「ORBIT PLAYGROUND」を、ブラウザーで操作している画面')], - note: '実演映像内の表記では、完成版の作成にはCodexを使っています。作例のHTMLは、下にある「できたものを動かす」から開けます。'} + note: '実演映像内の表記では、完成版の作成にはCodexを使っています。作例のHTMLは、下にある「動かしてみる」から開けます。'} ]); -const SETUP = [ - ['01', '必要環境を確かめる', 'macOS 14以降。ローカルサービスと、使うモデルを用意します。'], - ['02', '手順どおりに設定する', 'GitHubのセットアップ手順に沿って、アプリとモデル接続を設定します。'], - ['03', 'メモを添えて頼む', '⌥+Spaceで呼び出し、メモから実行計画を作ってみてください。'] -]; +const DEMOS_EN = Object.freeze({ + proposal: {ask: 'Make this page’s headline say it better', shows: 'Original page → request → headline and button ideas, with reasons', title: 'Rework how a web page reads', + alts: ['The request field asking for one improvement to the site’s headline and button, with a length and conditions', + 'The answer: a reason, the headline “中小企業向けコーポレートサイト制作・改善” (corporate sites for small businesses) and the button “詳細を見る” (See details)'], + note: 'Generated with a local Ollama model, told not to add prices or track records that are not on the screen.'}, + priorities: {ask: 'Sort out what to do next from these numbers', shows: 'Original numbers → priorities → next step', title: 'Decide what to do next', + alts: [FRAMES_EN.question, FRAMES_EN.answer], + note: 'Fictional demo data. The answer says its order is provisional and that these numbers alone cannot show how much each change would help.'}, + prototype: {ask: 'Turn this idea into a working prototype', shows: 'Request → generated HTML → a result you can click through', title: 'Make a small HTML page', + alts: ['The request field asking to turn a doodle into a small universe you can touch, with conditions for how to build it', + 'The orbiting-planet HTML “ORBIT PLAYGROUND” being used in a browser'], + note: 'As labelled in the demonstration film, the final version was made with Codex. Open the example HTML from “Try it” below.'} +}); -function genie(products) { +// Every visible string that is not a ledger fact, per language. Japanese is the original; English follows it section by section. +const COPY = Object.freeze({ + ja: { + how: [['01', '呼び出す', 'idle'], ['02', '必要な情報を渡す', 'listen'], ['03', '結果を使う', 'done']], + howAlt: { + idle: '作業中のダッシュボードの上端に、Genie の小さな入口がある', + listen: 'TaskDock が開き、「この数字、要点だけまとめて」という発話を聞いている', + done: 'TaskDock に、保存した Markdown ファイルと「開く」「コピー」が出ている' + }, + keys: 'どのアプリの上からでも', + howCaption: '次の版の TaskDock(開発中)の動きを再現した図です。背景は実アプリの紹介映像から切り出した画面 · デモ用の架空データ', + dock: {rec: '録音', listening: '聞いています', said: '「この数字、要点だけまとめて」', context: '見ている画面 · 送信するまで渡しません', + ready: '要点ができました · 保存済み', summary: '3つの要点にまとめました', file: 'Genie-要点.md', fileMeta: '書類 · たった今', open: '開く', copy: 'コピー'}, + setup: [ + ['01', '必要環境を確かめる', 'macOS 14以降。ローカルサービスと、使うモデルを用意します。'], + ['02', '手順どおりに設定する', 'GitHubのセットアップ手順に沿って、アプリとモデル接続を設定します。'], + ['03', 'メモを添えて頼む', '⌥+Spaceで呼び出し、メモから実行計画を作ってみてください。'] + ], + request: '依頼', result: '結果', zoom: '拡大して見る', demoCaption: '実アプリの公開実演から切り出した画面です。入力は架空の例です。', quote: s => `「${s}」`, + demosTitle: 'たとえば、
こんな仕事の途中で。', + demosLead: 'ページの言い回し、数字の読み方、小さな試作。依頼を選ぶと、実演時の入力と結果が見られます。押しても新しいAI処理は行いません。', + demosPage: '/products/genie/demos/', demosLink: '実演を動画で見る', + artifactsTitle: '答えは、
ファイルで持ち帰る。', + artifactsLead: 'Markdownを開き直す。小さなHTMLを動かす。実演のファイルを、ここから開けます。', + mdMeta: 'MARKDOWN · 保存済み', mdLang: '', mdFoot: 'デモ用の架空データから作成', mdOpen: 'Markdownを開く', + orbitMeta: 'HTML · 操作できる作例', orbitFoot: '完成版はCodexで作成 · 別タブで開きます', orbitOpen: '動かしてみる', + trust: ['使うモデルを選ぶ。', '画面を渡すのは、頼むときだけ。', '使う前に、用意するもの。'], + trustTitle: '何を渡すか。
どのモデルに頼むか。', trustLink: '設定・送信先の条件を読む', + filmNote: '次の版のTaskDock(開発中)の動きを再現した約13秒の映像です。背景は実アプリの紹介映像から切り出した画面で、データは架空です。実アプリの録画ではありません。', + filmBar: '再現映像 · 開発中の次の版 / 約13秒', filmAlt: '作業中のダッシュボードの上端に、Genie の小さな入口がある(再現)', + filmLabel: 'Genie 次の版のTaskDockの動き(再現映像)', filmPlay: '13秒で動きを見る', filmProof: 'この映像とは別に確認できるもの — ', + headOld: '

主要機能・できること

いま確かめられる、
主な機能。

', + headNew: '

01 · HOW IT FEELS

この画面について、
ひとつ聞きたい。

スクリーンショットを添えれば、見ている内容をもとに依頼できます。答えをコピーしたり、Markdownで保存したりして、元の作業を続けられます。

', + h1: '作業の途中で、
Genieを呼び出す。', + lead: '

⌥Spaceどのアプリからでも。

見ているページについて聞きたい。数字の読み方を相談したい。Genieは、いまの画面を添えて頼めるMacのAIアシスタントです。

', + startKicker: '試す', startTitle: 'まずは、
手元のメモから。', + startLead: '設定が済んだら、短いメモをひとつ。実行計画を頼み、返ってきた結果を保存するところまで試せます。', + nav: ['実演', '動き'], + label: ['

録画の見どころ

', '

映像の見どころ

'], + // The Japanese page carries the 15-second film; the English page gains it from fifteen-second-films.mjs as well. + insertBefore: '
`“${s}”`, + demosTitle: 'For example,
in the middle of tasks like these.', + demosLead: 'The wording of a page, how to read numbers, a small prototype. Pick a request to see the input and result from the demonstration. Pressing it does not start any new AI processing.', + demosPage: '/en/products/genie/demos/', demosLink: 'Watch the demonstrations', + artifactsTitle: 'Take the answer
home as a file.', + artifactsLead: 'Reopen the Markdown. Run the small HTML. Open the files from the demonstration here.', + mdMeta: 'MARKDOWN · saved', mdLang: ' lang="ja"', mdFoot: 'Made from fictional demo data · the saved file is in Japanese', mdOpen: 'Open the Markdown', + orbitMeta: 'HTML · interactive example', orbitFoot: 'Final version made with Codex · opens in a new tab', orbitOpen: 'Try it', + trust: ['Choose the model.', 'Your screen is sent only when you ask.', 'What to prepare first.'], + trustTitle: 'What to hand over.
Which model to ask.', trustLink: 'Read the settings and where data goes', + filmNote: 'An approximately 13-second reconstruction of how TaskDock works in the next version (in development). The background is cut from the real app’s introduction film, and the data is fictional. It is not a recording of the real app.', + filmBar: 'Reconstruction · next version in development / ~13 s', filmAlt: 'A small Genie entry point at the top edge of a working dashboard (reconstruction)', + filmLabel: 'Genie: how TaskDock works in the next version (reconstruction)', filmPlay: 'See it in 13 seconds', filmProof: 'What you can check apart from this film — ', + headOld: '

KEY CAPABILITIES

From a quick question to a draft you can use.

', + headNew: '

01 · HOW IT FEELS

One question
about this screen.

Attach a screenshot and ask about what you are looking at. Copy the answer or save it as Markdown, then carry on with your work.

', + h1: 'In the middle of work,
call up Genie.', + lead: '

⌥SpaceFrom any app.

A question about the page you are looking at. Advice on how to read some numbers. Genie is a Mac AI assistant you can ask with your current screen attached.

', + startKicker: 'TRY IT', startTitle: 'Start with
a note you already have.', + startLead: 'Once it is set up, write one short note. Ask for an action plan and save what comes back.', + nav: ['Recording', 'Motion'], + label: ['

WHAT TO LOOK FOR

', '

WHAT TO LOOK FOR

'], + insertBefore: '
x.id === 'genie'); - if (!p?.ja?.features?.length || p.ja.features.length !== 3 || p.ja.highlights?.length !== 3) throw new TypeError('Genie ledger entry is incomplete'); + if (!p?.[lang]?.features?.length || p[lang].features.length !== 3 || p[lang].highlights?.length !== 3) throw new TypeError('Genie ledger entry is incomplete'); return p; } -const img = (f, cls, lazy = true) => `${e(f.alt)}`; +const img = (f, cls, alt, lazy = true) => `${e(alt)}`; +const frameAlt = (lang, key) => (lang === 'en' ? FRAMES_EN[key] : FRAMES[key].alt); const mark = () => ``; /** TaskDock(1b)を静的な HTML で描く。動きは CSS だけ(Reduce Motion では止まる)。 */ -function dock(state) { - if (state === 'idle') return `
${mark()}Genie録音
`; - if (state === 'listen') return `
${mark()}聞いています「この数字、要点だけまとめて」${''.repeat(7)}

Pulse — Launch overview見ている画面 · 送信するまで渡しません

`; - return `
${mark()}要点ができました · 保存済み3つの要点にまとめました

Genie-要点.md書類 · たった今開くコピー

`; +function dock(state, d) { + if (state === 'idle') return `
${mark()}Genie${d.rec}
`; + if (state === 'listen') return `
${mark()}${d.listening}${d.said}${''.repeat(7)}

Pulse — Launch overview${d.context}

`; + return `
${mark()}${d.ready}${d.summary}

${d.file}${d.fileMeta}${d.open}${d.copy}

`; } -const HOW_ALT = { - idle: '作業中のダッシュボードの上端に、Genie の小さな入口がある', - listen: 'TaskDock が開き、「この数字、要点だけまとめて」という発話を聞いている', - done: 'TaskDock に、保存した Markdown ファイルと「開く」「コピー」が出ている' -}; -function howFrames() { - return `
${HOW.map(([n, t, k], i) => ``).join('')}
次の版の TaskDock(開発中)の動きを再現した図です。背景は実アプリの紹介映像から切り出した画面 · デモ用の架空データ
`; +function howFrames(c, lang) { + return `
${c.how.map(([n, t, k], i) => ``).join('')}
${c.howCaption}
`; } // 最後の導線: ページを通ってきた青い流線が、小さな印に戻る。 const RETURN = ``; -function demosSection() { - const shot = (x, label) => ``; - const items = DEMOS.map((d, i) => `
${shot(d.steps[0], '依頼')}${shot(d.steps[1], '結果')}
${e(d.title)} — 実アプリの公開実演から切り出した画面です。入力は架空の例です。${e(d.note)}
`).join(''); - return `

02 · REAL WORKFLOWS

たとえば、
こんな仕事の途中で。

ページの言い回し、数字の読み方、小さな試作。依頼を選ぶと、実演時の入力と結果が見られます。押しても新しいAI処理は行いません。

${items}

実演を動画で見る

`; +function demosSection(c, lang) { + const shot = (x, alt, label) => ``; + const items = DEMOS.map((d, i) => { + const t = lang === 'en' ? DEMOS_EN[d.id] : {...d, alts: d.steps.map(x => x.alt)}; + return `
${shot(d.steps[0], t.alts[0], c.request)}${shot(d.steps[1], t.alts[1], c.result)}
${e(t.title)} — ${c.demoCaption}${e(t.note)}
`; + }).join(''); + return `

02 · REAL WORKFLOWS

${c.demosTitle}

${c.demosLead}

${items}

${c.demosLink}

`; } -function artifactsSection() { - return `

03 · WHAT STAYS

答えは、
ファイルで持ち帰る。

Markdownを開き直す。小さなHTMLを動かす。実演のファイルを、ここから開けます。

Genie-Next-Steps.mdMARKDOWN · 保存済み

この数字から、改善の優先順位を3つ教えて

  1. 初回行動への誘導:登録1,872人のうち初回行動は374人、到達率20.0%。次の一手:登録後のどの操作で離脱するか調べ、案内を改善する。
  2. 登録導線:訪問12,480人のうち登録は15.0%。次の一手:登録開始から完了までの離脱を測り、フォームや訴求を見直す。
  3. 集客の質:訪問は+24% this week。次の一手:流入元別に初回行動の到達率を比較し、成果につながる流入を増やす。

これは暫定順位です。離脱原因や各施策の改善効果は、この数字だけでは分かりません。

デモ用の架空データから作成Markdownを開く

orbit.htmlHTML · 操作できる作例

${img(FRAMES.orbit, 'glp-shot')}

完成版はCodexで作成 · 別タブで開きます${ext(`${O}orbit.html`, '動かしてみる')}

`; +function artifactsSection(c, lang) { + return `

03 · WHAT STAYS

${c.artifactsTitle}

${c.artifactsLead}

orbit.html${c.orbitMeta}

${img(FRAMES.orbit, 'glp-shot', frameAlt(lang, 'orbit'))}

${c.orbitFoot}${ext(`${O}orbit.html`, c.orbitOpen)}

`; } -function trustSection(p) { - const t = p.ja, f = t.features, h = t.highlights; +function trustSection(p, c, lang) { + const t = p[lang], f = t.features, h = t.highlights; const items = [ - ['使うモデルを選ぶ。', `${h[1].value} — ${h[1].label}`], - ['画面を渡すのは、頼むときだけ。', f[1].body], - ['使う前に、用意するもの。', t.scope] + [c.trust[0], `${h[1].value} — ${h[1].label}`], + [c.trust[1], f[1].body], + [c.trust[2], t.scope] ]; - return `

04 · YOUR CALL

何を渡すか。
どのモデルに頼むか。

${ext(p.evidence, '設定・送信先の条件を読む')}
${items.map(([q, a]) => `
${e(q)}
${e(a)}
`).join('')}
`; + return `

04 · YOUR CALL

${c.trustTitle}

${ext(p.evidence, c.trustLink)}
${items.map(([q, a]) => `
${e(q)}
${e(a)}
`).join('')}
`; } // The top film: 01's TaskDock reconstruction, written out as a 13-second video. Never called a recording. -export const TOP_FILM = Object.freeze({src: '/media/films/genie-taskdock-13s.mp4', poster: '/media/films/genie-taskdock-13s.jpg', - note: '次の版のTaskDock(開発中)の動きを再現した約13秒の映像です。背景は実アプリの紹介映像から切り出した画面で、データは架空です。実アプリの録画ではありません。'}); +export const TOP_FILM = Object.freeze({src: '/media/films/genie-taskdock-13s.mp4', poster: '/media/films/genie-taskdock-13s.jpg', note: COPY.ja.filmNote}); const HERO_FILM = /
[\s\S]*?<\/figure>/; -const heroFilm = proof => `
Genie再現映像 · 開発中の次の版 / 約13秒
作業中のダッシュボードの上端に、Genie の小さな入口がある(再現)
${e(TOP_FILM.note)}この映像とは別に確認できるもの — ${e(proof)}実演を動画で見る
`; -const HEAD_OLD = '

主要機能・できること

いま確かめられる、
主な機能。

'; -const HEAD_NEW = '

01 · HOW IT FEELS

この画面について、
ひとつ聞きたい。

スクリーンショットを添えれば、見ている内容をもとに依頼できます。答えをコピーしたり、Markdownで保存したりして、元の作業を続けられます。

'; +const heroFilm = (proof, c) => `
Genie${c.filmBar}
${e(c.filmAlt)}
${e(c.filmNote)}${c.filmProof}${e(proof)}${c.demosLink}
`; const H1 = /(

]*>)[\s\S]*?(<\/h1>)/; -const START_H2 = /(

)試す(<\/p>)

[\s\S]*?<\/h2>/; +const startH2 = kicker => new RegExp(`(

)${kicker}(<\\/p>)

[\\s\\S]*?<\\/h2>`); const once = (html, needle, label) => { const at = html.indexOf(needle); if (at < 0 || html.indexOf(needle, at + 1) >= 0) throw new Error(`Genie LP: expected exactly one ${label}`); @@ -131,36 +224,37 @@ const before = (html, needle, label, insert) => { const at = once(html, needle, /** Fail closed: every anchor below is built by an earlier pass; a missing one stops the build. */ export function refineGenieLp(html, products = ledgerProducts) { if (html.includes(`data-genie-lp="${GENIE_LP_VERSION}"`)) return html; - const p = genie(products); - if (!//.test(html) || !html.includes('data-product-id="genie"')) throw new Error('Genie LP: not the Japanese Genie page'); + const lang = //.exec(html)?.[1]; + if (!lang || !html.includes('data-product-id="genie"')) throw new Error('Genie LP: not a Japanese or English Genie page'); + const p = genie(products, lang), c = COPY[lang], START_H2 = startH2(c.startKicker); if (!H1.test(html)) throw new Error('Genie LP: expected the Genie heading with its icon'); if (!START_H2.test(html)) throw new Error('Genie LP: expected the start section'); - once(html, HEAD_OLD, 'features heading'); + once(html, c.headOld, 'features heading'); const films = html.match(new RegExp(HERO_FILM.source, 'g')) || []; if (films.length !== 1 || !films[0].includes('data-recording-src="/media/products/genie.mp4"')) throw new Error('Genie LP: expected exactly one top recording'); - once(html, '実演', 'recording nav link'); - once(html, '

録画の見どころ

', 'recording highlight label'); - const lead = '

⌥Spaceどのアプリからでも。

見ているページについて聞きたい。数字の読み方を相談したい。Genieは、いまの画面を添えて頼めるMacのAIアシスタントです。

'; - html = html.replace(H1, (_, a, b) => `${a}作業の途中で、
Genieを呼び出す。${b}${lead}`); - html = html.replace(HEAD_OLD, () => HEAD_NEW); - html = html.replace(HERO_FILM, () => heroFilm(p.ja.proof)).replace('実演', '動き').replace('

録画の見どころ

', '

映像の見どころ

'); - html = before(html, '

', 'features source', howFrames()); - html = before(html, '

`${a.replace('

', RETURN + '

')}05 · START${b}

まずは、
手元のメモから。

設定が済んだら、短いメモをひとつ。実行計画を頼み、返ってきた結果を保存するところまで試せます。

`); - html = before(html, '
', 'start actions', `
    ${SETUP.map(([n, t, b]) => `
  1. ${n}${e(t)}${e(b)}
  2. `).join('')}
`); + once(html, c.nav[0], 'recording nav link'); + once(html, c.label[0], 'recording highlight label'); + html = html.replace(H1, (_, a, b) => `${a}${c.h1}${b}${c.lead}`); + html = html.replace(c.headOld, () => c.headNew); + html = html.replace(HERO_FILM, () => heroFilm(p[lang].proof, c)).replace(c.nav[0], c.nav[1]).replace(c.label[0], c.label[1]); + html = before(html, '

', 'features source', howFrames(c, lang)); + html = before(html, c.insertBefore, '15-second film', demosSection(c, lang) + artifactsSection(c, lang)); + html = before(html, '

`${a.replace('

', RETURN + '

')}05 · START${b}

${c.startTitle}

${c.startLead}

`); + html = before(html, '
', 'start actions', `
    ${c.setup.map(([n, t, b]) => `
  1. ${n}${e(t)}${e(b)}
  2. `).join('')}
`); return html.replace(' f.src), ...DEMOS.flatMap(d => d.steps.map(x => x.src)), TOP_FILM.src, TOP_FILM.poster, `${O}orbit.html`, `${O}assets/Genie-Next-Steps.md`, '/assets/genie-lp.mjs']; for (const asset of required) await fs.access(path.join(dist, asset)).catch(() => { throw new Error(`Genie LP: missing ${asset}`); }); - const file = path.join(dist, PAGE); - const html = refineGenieLp(await fs.readFile(file, 'utf8'), products); + for (const page of Object.values(PAGES)) { + const file = path.join(dist, page); + await fs.writeFile(file, refineGenieLp(await fs.readFile(file, 'utf8'), products)); + } const assets = path.join(dist, 'assets'); const [css, js, layer] = await Promise.all(['showcase.css', 'showcase.mjs', 'genie-lp.css'].map(f => fs.readFile(path.join(assets, f), 'utf8'))); if (!layer.includes('[data-genie-lp]')) throw new Error('Genie LP stylesheet is missing or stale'); - await fs.writeFile(file, html); await fs.writeFile(path.join(assets, 'showcase.css'), css.split(CSS_MARK)[0].trimEnd() + `\n${CSS_MARK}\n${layer}`); await fs.writeFile(path.join(assets, 'showcase.mjs'), js.split(JS_MARK)[0].trimEnd() + `\n${JS_MARK}\nif(document.body.dataset.genieLp)import('./genie-lp.mjs').catch(()=>{document.documentElement.dataset.genieLp='unavailable';});\n`); } diff --git a/src/oathra-lp.mjs b/src/oathra-lp.mjs index 933a23a..7262b72 100644 --- a/src/oathra-lp.mjs +++ b/src/oathra-lp.mjs @@ -1,7 +1,9 @@ /** - * Japanese Oathra page, re-composed from the owner's design brief "Oathra LP 改善案" (2026-09-29). + * Oathra page, re-composed from the owner's design brief "Oathra LP 改善案" (2026-09-29). Japanese first; the English + * page (2026-10-19) follows it section by section. * - * Runs as the last pass on dist/products/oathra/index.html and rebuilds
in the brief's order: + * Runs as the last pass on dist/products/oathra/index.html and dist/en/products/oathra/index.html and rebuilds
+ * in the brief's order: * 1 first view (headline, one sentence, the 30-second image film, one button) * 2 the ring (the judgement: only the other party's words close a segment) * 3 from request to report (four stills of the design, labelled as not released) @@ -26,7 +28,7 @@ import {landingExperience} from './product-landings.mjs'; export const OATHRA_LP_VERSION = '20261010-oathra-lp-4'; export const CONCEPT_DISCLAIMER = '構想デモ・実際の発信/予約は行いません'; const CSS_MARK = '/* REACHMADE_OATHRA_LP */'; -const PAGE = 'products/oathra/index.html'; +const PAGES = Object.freeze({ja: 'products/oathra/index.html', en: 'en/products/oathra/index.html'}); const A = '/assets/products/oathra/'; export const FILM = Object.freeze({src: '/media/films/oathra-30s.mp4', poster: '/media/films/oathra-30s.jpg'}); export const STEPS = Object.freeze([ @@ -35,10 +37,122 @@ export const STEPS = Object.freeze([ {src: `${A}lp-step-03.jpg`, title: '会話を見守る', body: '相手が答えた項目を、その場で確認する。いつでも通話を終えられる。', alt: '通話中の画面。輪が3/3で閉じ、日付・時刻・人数の3項目が相手の言葉で確認済みになっている'}, {src: `${A}lp-step-04.jpg`, title: '決まったことを読む', body: '頼んだ内容との違いと、あなたが引き継ぐことも報告する。', alt: '「決まりました」の報告画面。日付・時刻・人数と、それぞれの根拠の発言と時刻'} ]); +const STEPS_EN = Object.freeze([ + {title: 'Write the errand', body: 'Write who to call and what you need, in everyday words.', alt: 'The “Make a call” screen: the other party 焼肉 たけ, a request for a table for two at 19:00 on Saturday 3 October, and three columns for what to hand over'}, + {title: 'Check, then call', body: 'See the other party, the request, the cost ceiling and how the record is handled. Nothing is dialled until you approve.', alt: 'Instructions for the AI, the check “I have confirmed the other party, the request and the cost”, and the button “Call with these details”'}, + {title: 'Watch the conversation', body: 'Check each item as the other party answers it. You can end the call at any time.', alt: 'A call in progress: the ring closed at 3/3, with date, time and party size confirmed in the other party’s words'}, + {title: 'Read what was decided', body: 'The report also covers differences from what you asked for, and what you need to take over.', alt: 'The “Decided” report: date, time and party size, each with the words that support it and when they were said'} +]); +// The concept demos run in Japanese only; the English page explains them in English and says the UI is Japanese. +const CONCEPT_DEMOS_EN = Object.freeze({ + restaurant: {kind: 'Restaurant booking', title: 'Ask a nearby restaurant for a table.', body: 'Check budget, date and party size. Approve the number to call and what to tell them, then check the result of the simulated call.', linkLabel: 'Try a restaurant booking (Japanese UI)', + result: 'Booking confirmed (simulated). No real booking was made.', + steps: ['Pick a restaurant from synthetic nearby candidates and check budget, date and party size.', 'Approve the fictional number, what to disclose and how much of the booking to hand over.', 'On the result screen, check the replies in the simulated call and whether the booking conditions were met.']}, + stock: {kind: 'Stock and hold', title: 'Can they hold this model for me?', body: 'Check the model number, price and pickup deadline. Ask, in a simulated call, for a hold with no charge and no obligation to buy.', linkLabel: 'Try a stock check (Japanese UI)', + result: 'Free hold confirmed (simulated): model AZ1000, ¥6,800, until the pickup deadline. No obligation to buy.', + steps: ['Check the model number and shop from synthetic data.', 'Approve only a price ceiling, a pickup deadline and a hold with no charge or obligation to buy.', 'Run the simulated call and check the model, price, deadline and the result of the hold.']}, + modify: {kind: 'Changing a booking', title: 'Move the 7 pm booking to 8 pm.', body: 'If it cannot change, the original 7 pm stays. Check the change you asked for and the outcome separately.', linkLabel: 'Try changing a booking (Japanese UI)', + result: 'The other party declined, so the change did not go through and the original 7 pm booking stays (simulated). Ending the call is not shown as a successful change.', + steps: ['Check the fictional booking number and the current 7 pm booking.', 'Ask to move it to 8 pm, and approve keeping the original booking if that is not possible.', 'Read the replies in the simulated call and check the change and the original booking separately.']} +}); const e = s => String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); const arrow = ''; const ext = (href, label, cls = '') => `${e(label)} ${arrow}`; +// Every visible string that is not a ledger fact or a moved piece, per language. +const COPY = Object.freeze({ + ja: { + h1: '「できました」を、
根拠にしない。', + lead: 'その予約、相手は何と言った? OathraはAIによる電話の結果を、相手の発言と時刻に結びつけて報告します。日時・人数・金額を、あとからたどれます。', + code: 'コードをGitHubで読む', + filmTag: 'イメージ映像(演出を含む)· 設計画面 · 未リリース', filmLabel: 'Oathraのイメージ映像(30秒・音声なし・演出を含む)', fallback: 'この動画は、このブラウザーでは再生できません。', + filmCaption: '30秒・音声なし・押したときだけ再生。紹介のために作ったイメージ映像です。映像の中の画面はこれから作るアプリの設計で、現行のOathraにはまだありません。', + ringCards: [ + ['まず、確認する項目を決める', '日付・時刻・人数なら、輪を3つに区切ります。'], + ['相手が答えた項目を閉じる', '根拠になった相手の発言と、その開始・終了時刻を記録します。'], + ['AIの言葉では閉じない', 'AIが「予約できました」と言っても、判定には数えません。'] + ], + ringStates: ['確認前', '相手が確認', 'AIの発言'], + ringKicker: '01 · 輪のしくみ', ringTitle: '相手の言葉でだけ、
輪が閉じる。', + ringLead: '日時や人数がそろっていても、AIが言っただけでは確認済みにしません。その判定の考え方を、輪で表しています。', + ringFigure: '日付・時刻・人数の3項目が、相手の言葉で確認済み', + ringNote: '緑の区切りは、相手の言葉で確認できた項目です。店舗システムに登録されたことまでは示しません。', + demosKicker: 'OATHRA / RINGZERO · 操作できる構想デモ', demosTitle: '電話を頼む前に、
ひとつ、試してみる。', + demosLead: '条件を決める。任せる範囲を承認する。相手の返答と結果を確かめる。3つの用件を、ブラウザーで操作できます。', + disclaimer: CONCEPT_DISCLAIMER, demosData: '合成データ・架空の店舗、電話番号、予約番号のみ。料金・購入義務も発生しません。', + demoKinds: ['飲食店予約', '在庫と取り置き', '予約の時間変更'], + demoLabel: title => `${title} 構想デモの操作動画・日本語字幕・音声なし`, demoFallback: 'このブラウザーでは動画を再生できません。', demoFile: '動画ファイルを開く', + demoMeta: s => `${s}秒 · 操作できるデモUIの連続収録 · 日本語字幕 · 音声なし`, demoSteps: '操作の流れをテキストで読む', demoResult: '動画の結果:', + demoSimulated: 'すべて架空データによる模擬の結果です。実際の発信・予約・購入は行いません。', + demosNote: '動画と操作画面は、合成の相手応答を使うシミュレーションです。下の従来の設計映像・判定記録とは別のデモで、実電話や店舗システムへの登録を検証したものではありません。条件外の費用、不通、拒否、曖昧な返答は、成功と分けて扱います。', + stepsKicker: '02 · 頼んでから報告まで', stepsTitle: '電話の前後に、
確認したいこと。', + stepsLead: '相手、用件、費用を見てから発信し、終わったら報告を読む。その流れを考えた、これから作るアプリの設計画面です。4枚は上のイメージ映像から切り出しています。現行のOathra(CLIとシミュレーター)には、この画面はまだありません。', + stepsLabel: '開発中の画面 · 未リリース', + stepsNote: '別公開の設計動画は、次のUIを考えるためのプレビューです。現行製品の実演や、新UIの実装完了を示すものではありません。', + delegation: [ + ['ok', '○ AIが決めてよい', '例:時間は第一希望から2時間以内'], + ['hold', '△ 決めずに持ち帰る', '例:コースや前金が必要と言われた'], + ['no', '✕ しない', '支払いの約束、AIであることを隠す'] + ], + trustKicker: '03 · 任せる範囲と承認', trustTitle: '任せていいのは、
ここまで。', trustLabel: '設計 · 実電話フローには未実装', + trustLead: '従来の設計案です。実電話フローには、この3段階の指定、発信前の承認、費用の上限による打ち切り、着信の応対はまだありません。上の操作できる構想デモでは、承認と条件の確認をシミュレーション内で試せます。', + trustNote: '設計では、本番の電話は毎回承認が必要で、この設定は外せません。費用は1回ごとの上限つきです。電話がかかってきたときは、AIが用件だけを聞きます。', + recKicker: '04 · 実録画', recTitle: 'どの一言で、
確定と判断したのか。', recLead: '現行版で確認できる判定の流れを、実録画と紹介映像でご覧ください。', + recItem: '判定画面 · 約13秒の実録画', recItemLead: '最初に出るのは、判定の流れを説明する再現です。「実録画を見る」で、13秒の実録画に切り替わります。', + recFilm: '15秒の紹介映像', recCode: 'コードと記録をGitHubで見る', + statusHeads: ['現在できること', '設定・費用が必要', '対象外・検証前'], + statusCan: (ms, speech, license) => [`会話上の合意の判定 — ${speech.label}`, `根拠の区間の記録 — ${ms.label}`, `${license.value} — 開発者向けに、${license.label}`], + statusSetup: ['実際の電話(設定と費用が必要)'], statusNot: ['店舗システムへの登録', '100件の実電話検証'], + statusKicker: '05 · 現在地', statusTitle: '会話の合意と、
予約の登録は別です。', statusTable: 'Oathraの現在地', evidence: '検証資料を見る', + nav: [['href="#features">機能', 'href="#ring">しくみ'], ['href="#flow">流れ', 'href="#steps">流れ']] + }, + en: { + h1: '“Done” is not
evidence.', + lead: 'What did the other party actually say about that booking? Oathra reports the result of an AI phone call tied to the other party’s words and when they said them, so dates, party size and amounts can be traced afterwards.', + code: 'Read the code on GitHub', + filmTag: 'Concept film (staged) · design screens · not released', filmLabel: 'Oathra concept film (30 s, silent, staged)', fallback: 'This video cannot be played in this browser.', + filmCaption: '30 s · silent · plays only when pressed. A concept film made for this introduction. The screens in it are designs for an app still to be built; the current Oathra does not have them yet.', + ringCards: [ + ['First, decide what to confirm', 'For date, time and party size, the ring is split into three.'], + ['Close each item they answer', 'Record the other party’s words that serve as evidence, with their start and end times.'], + ['The AI’s words never close it', 'Even if the AI says “the table is booked”, it does not count toward the judgement.'] + ], + ringStates: ['Before checking', 'Confirmed by them', 'The AI’s words'], + ringKicker: '01 · THE RING', ringTitle: 'Only their words
close the ring.', + ringLead: 'Even when the date, time and party size are all there, nothing counts as confirmed just because the AI said it. The ring shows how that judgement works.', + ringFigure: 'Date, time and party size, all confirmed in the other party’s words', + ringNote: 'A green segment is an item confirmed in the other party’s words. It does not show that the booking was entered in the shop’s system.', + demosKicker: 'OATHRA / RINGZERO · INTERACTIVE CONCEPT DEMOS', demosTitle: 'Before you hand over a call,
try one.', + demosLead: 'Set the conditions. Approve what you hand over. Check the reply and the result. Three errands you can operate in the browser (Japanese UI).', + disclaimer: 'Concept demo · no real calls or bookings', demosData: 'Synthetic data and fictional shops, phone numbers and booking numbers only. No charges and no obligation to buy.', + demoKinds: null, + demoLabel: title => `${title} concept demo walkthrough · Japanese subtitles · silent`, demoFallback: 'This browser cannot play the video.', demoFile: 'Open the video file', + demoMeta: s => `${s} s · continuous capture of the interactive demo UI · Japanese subtitles · silent`, demoSteps: 'Read the steps as text', demoResult: 'Result in the video: ', + demoSimulated: 'All results are simulated with fictional data. No real calls, bookings or purchases are made.', + demosNote: 'The videos and demo screens are simulations with synthetic replies. They are separate from the earlier design film and judgement records below, and have not been tested with real calls or entries in a shop’s system. Costs outside the conditions, no answer, refusals and unclear replies are kept apart from success.', + stepsKicker: '02 · FROM REQUEST TO REPORT', stepsTitle: 'What you want to check,
before and after a call.', + stepsLead: 'See the other party, the errand and the cost before calling; read the report when it is done. These are design screens for an app still to be built, cut from the concept film above. The current Oathra (CLI and simulator) does not have them yet.', + stepsLabel: 'Screens in development · not released', + stepsNote: 'The separate design film is a proposal for a future interface. It is not a demonstration of the current product or proof that the proposed UI has shipped.', + delegation: [ + ['ok', '○ The AI may decide', 'e.g. a time within two hours of the first choice'], + ['hold', '△ Bring it back undecided', 'e.g. they say a set menu or deposit is required'], + ['no', '✕ Never', 'Promising payment, or hiding that it is an AI'] + ], + trustKicker: '03 · WHAT YOU HAND OVER', trustTitle: 'This far,
and no further.', trustLabel: 'Design · not in the real-call flow', + trustLead: 'An earlier design. The real-call flow does not yet have these three levels, approval before dialling, a stop at a cost ceiling, or answering incoming calls. In the interactive concept demos above, you can try approval and condition checks inside the simulation.', + trustNote: 'In the design, every real call needs approval and this cannot be turned off. Each call has a cost ceiling. When a call comes in, the AI only takes the message.', + recKicker: '04 · RECORDINGS', recTitle: 'Which words
settled it?', recLead: 'See the judgement flow you can check in the current version, in a real recording and an introduction film.', + recItem: 'Judgement screen · ~13-second real recording', recItemLead: 'It opens on a reconstruction that explains the judgement. “Real recording” switches to the 13-second real recording.', + recFilm: '15-second introduction film', recCode: 'See the code and records on GitHub', + statusHeads: ['What it does now', 'Needs setup and cost', 'Out of scope / not yet verified'], + statusCan: (ms, speech, license) => [`Judging agreement in the conversation — ${speech.label}`, `Recording the evidence span — ${ms.label}`, `${license.value} — for developers: ${license.label}`], + statusSetup: ['Real phone calls (setup and cost required)'], statusNot: ['Entry into a shop’s booking system', 'Evaluation with 100 real calls'], + statusKicker: '05 · WHERE IT STANDS', statusTitle: 'Agreement in a call is not
a booking in a system.', statusTable: 'Where Oathra stands', evidence: 'See the evidence', + nav: [['href="#features">Features', 'href="#ring">How it works'], ['href="#flow">Flow', 'href="#steps">Flow']] + } +}); + /** Returns the outer HTML of the first element that starts with `open`, balancing tags of the same name. */ export function outer(html, open, what) { const start = html.indexOf(open); @@ -54,9 +168,9 @@ export function outer(html, open, what) { throw new Error(`Oathra LP: unbalanced ${what}`); } -function oathra(products) { +function oathra(products, lang = 'ja') { const p = products.find(x => x.id === 'oathra'); - if (!p?.ja?.highlights || p.ja.highlights.length !== 3) throw new TypeError('Oathra LP: the ledger entry is missing its highlights'); + if (!p?.[lang]?.highlights || p[lang].highlights.length !== 3) throw new TypeError('Oathra LP: the ledger entry is missing its highlights'); return p; } @@ -70,58 +184,57 @@ function ring(closed) { return ``; } -function hero(p, x) { - return `

${e(p.index)} / ${e(p.discipline)} / ${e(p.ja.status)}

「できました」を、
根拠にしない。

その予約、相手は何と言った? OathraはAIによる電話の結果を、相手の発言と時刻に結びつけて報告します。日時・人数・金額を、あとからたどれます。

${ext(x.primary[1], x.primary[0], 'owned-primary')}${ext(p.repo, 'コードをGitHubで読む', 'olp-link')}

${e(x.actionNote)}

` - + `

イメージ映像(演出を含む)· 設計画面 · 未リリース

30秒・音声なし・押したときだけ再生。紹介のために作ったイメージ映像です。映像の中の画面はこれから作るアプリの設計で、現行のOathraにはまだありません。
`; +function hero(p, x, c, lang) { + return `

${e(p.index)} / ${e(p.discipline)} / ${e(p[lang].status)}

${c.h1}

${c.lead}

${ext(x.primary[1], x.primary[0], 'owned-primary')}${ext(p.repo, c.code, 'olp-link')}

${e(x.actionNote)}

` + + `

${c.filmTag}

${c.filmCaption}
`; } -function ringSection() { - const cards = [ - ['まず、確認する項目を決める', '日付・時刻・人数なら、輪を3つに区切ります。', 0, ''], - ['相手が答えた項目を閉じる', '根拠になった相手の発言と、その開始・終了時刻を記録します。', 1, ' is-closed'], - ['AIの言葉では閉じない', 'AIが「予約できました」と言っても、判定には数えません。', 1, ' is-open'] - ]; - return `

01 · 輪のしくみ

相手の言葉でだけ、
輪が閉じる。

日時や人数がそろっていても、AIが言っただけでは確認済みにしません。その判定の考え方を、輪で表しています。

CONFIRMED IN THEIR WORDS${ring(3)}
日付・時刻・人数の3項目が、相手の言葉で確認済み
    ${cards.map(([t, b, count, cls], i) => `
  1. 0${i + 1} / ${count === 0 ? '確認前' : i === 1 ? '相手が確認' : 'AIの発言'}${e(t)}${e(b)}
  2. `).join('')}

緑の区切りは、相手の言葉で確認できた項目です。店舗システムに登録されたことまでは示しません。

`; +function ringSection(c) { + const cards = c.ringCards.map(([t, b], i) => [t, b, i === 0 ? 0 : 1, ['', ' is-closed', ' is-open'][i]]); + return `

${c.ringKicker}

${c.ringTitle}

${c.ringLead}

CONFIRMED IN THEIR WORDS${ring(3)}
${c.ringFigure}
    ${cards.map(([t, b, count, cls], i) => `
  1. 0${i + 1} / ${c.ringStates[i]}${e(t)}${e(b)}
  2. `).join('')}

${c.ringNote}

`; } -export function conceptDemosSection(p) { +export function conceptDemosSection(p, lang = 'ja') { if (!Array.isArray(p.conceptDemos) || p.conceptDemos.length !== 3) throw new Error('Oathra LP: expected three concept demos'); - return `

OATHRA / RINGZERO · 操作できる構想デモ

電話を頼む前に、
ひとつ、試してみる。

条件を決める。任せる範囲を承認する。相手の返答と結果を確かめる。3つの用件を、ブラウザーで操作できます。

${CONCEPT_DISCLAIMER}合成データ・架空の店舗、電話番号、予約番号のみ。料金・購入義務も発生しません。

${p.conceptDemos.map((demo, i) => `

0${i + 1} / ${i === 0 ? '飲食店予約' : i === 1 ? '在庫と取り置き' : '予約の時間変更'}

${e(demo.title)}

${e(demo.body)}

${ext(demo.href, demo.linkLabel, 'olp-demo__action')}
${CONCEPT_DISCLAIMER}${demo.durationSeconds}秒 · 操作できるデモUIの連続収録 · 日本語字幕 · 音声なし${ext(demo.src, '動画ファイルを開く', 'olp-demo__file')}
操作の流れをテキストで読む
    ${demo.steps.map(step => `
  1. ${e(step)}
  2. `).join('')}

動画の結果:${e(demo.result)}

すべて架空データによる模擬の結果です。実際の発信・予約・購入は行いません。

`).join('')}

動画と操作画面は、合成の相手応答を使うシミュレーションです。下の従来の設計映像・判定記録とは別のデモで、実電話や店舗システムへの登録を検証したものではありません。条件外の費用、不通、拒否、曖昧な返答は、成功と分けて扱います。

`; + const c = COPY[lang]; + const demos = p.conceptDemos.map((demo, i) => { + const t = lang === 'en' ? CONCEPT_DEMOS_EN[demo.id] : {...demo, kind: c.demoKinds[i]}; + if (!t) throw new Error(`Oathra LP: no English text for concept demo ${demo.id}`); + return {...demo, ...t}; + }); + return `

${c.demosKicker}

${c.demosTitle}

${c.demosLead}

${c.disclaimer}${c.demosData}

${demos.map((demo, i) => `

0${i + 1} / ${demo.kind}

${e(demo.title)}

${e(demo.body)}

${ext(demo.href, demo.linkLabel, 'olp-demo__action')}
${c.disclaimer}${c.demoMeta(demo.durationSeconds)}${ext(demo.src, c.demoFile, 'olp-demo__file')}
${c.demoSteps}
    ${demo.steps.map(step => `
  1. ${e(step)}
  2. `).join('')}

${c.demoResult}${e(demo.result)}

${c.demoSimulated}

`).join('')}

${c.demosNote}

`; } -function stepsSection(prototypeLink) { - return `

02 · 頼んでから報告まで

電話の前後に、
確認したいこと。

相手、用件、費用を見てから発信し、終わったら報告を読む。その流れを考えた、これから作るアプリの設計画面です。4枚は上のイメージ映像から切り出しています。現行のOathra(CLIとシミュレーター)には、この画面はまだありません。

    ${STEPS.map((s, i) => `
  1. ${e(s.alt)}
    開発中の画面 · 未リリース${i + 1}. ${e(s.title)}${e(s.body)}
  2. `).join('')}

別公開の設計動画は、次のUIを考えるためのプレビューです。現行製品の実演や、新UIの実装完了を示すものではありません。 ${prototypeLink}

`; +function stepsSection(prototypeLink, c, lang) { + const steps = STEPS.map((s, i) => (lang === 'en' ? {...s, ...STEPS_EN[i]} : s)); + return `

${c.stepsKicker}

${c.stepsTitle}

${c.stepsLead}

    ${steps.map((s, i) => `
  1. ${e(s.alt)}
    ${c.stepsLabel}${i + 1}. ${e(s.title)}${e(s.body)}
  2. `).join('')}

${c.stepsNote} ${prototypeLink}

`; } -function delegationSection() { - const cols = [ - ['ok', '○ AIが決めてよい', '例:時間は第一希望から2時間以内'], - ['hold', '△ 決めずに持ち帰る', '例:コースや前金が必要と言われた'], - ['no', '✕ しない', '支払いの約束、AIであることを隠す'] - ]; - return `

03 · 任せる範囲と承認

任せていいのは、
ここまで。

設計 · 実電話フローには未実装

従来の設計案です。実電話フローには、この3段階の指定、発信前の承認、費用の上限による打ち切り、着信の応対はまだありません。上の操作できる構想デモでは、承認と条件の確認をシミュレーション内で試せます。

${cols.map(([k, t, b]) => `
${e(t)}${e(b)}
`).join('')}

設計では、本番の電話は毎回承認が必要で、この設定は外せません。費用は1回ごとの上限つきです。電話がかかってきたときは、AIが用件だけを聞きます。

`; +function delegationSection(c) { + return `

${c.trustKicker}

${c.trustTitle}

${c.trustLabel}

${c.trustLead}

${c.delegation.map(([k, t, b]) => `
${e(t)}${e(b)}
`).join('')}

${c.trustNote}

`; } -function recordingsSection(p, heroFilm, film15) { +function recordingsSection(p, heroFilm, film15, c) { const figure15 = outer(film15, '

04 · 実録画

どの一言で、
確定と判断したのか。

現行版で確認できる判定の流れを、実録画と紹介映像でご覧ください。

判定画面 · 約13秒の実録画

最初に出るのは、判定の流れを説明する再現です。「実録画を見る」で、13秒の実録画に切り替わります。

${heroFilm}

15秒の紹介映像

${figure15}${note15}

${ext(p.repo, 'コードと記録をGitHubで見る')}

`; + return `

${c.recKicker}

${c.recTitle}

${c.recLead}

${c.recItem}

${c.recItemLead}

${heroFilm}

${c.recFilm}

${figure15}${note15}

${ext(p.repo, c.recCode)}

`; } -function statusSection(p, source, access) { - const [ms, speech, license] = p.ja.highlights; +function statusSection(p, source, access, c, lang) { + const [ms, speech, license] = p[lang].highlights; const rows = [ - ['現在できること', [`会話上の合意の判定 — ${speech.label}`, `根拠の区間の記録 — ${ms.label}`, `${license.value} — 開発者向けに、${license.label}`]], - ['設定・費用が必要', ['実際の電話(設定と費用が必要)']], - ['対象外・検証前', ['店舗システムへの登録', '100件の実電話検証']] + [c.statusHeads[0], c.statusCan(ms, speech, license)], + [c.statusHeads[1], c.statusSetup], + [c.statusHeads[2], c.statusNot] ]; - return `

05 · 現在地

会話の合意と、
予約の登録は別です。

${rows.map(([h, items]) => `

${e(h)}

    ${items.map(i => `
  • ${e(i)}
  • `).join('')}
`).join('')}
${access}

${e(p.ja.proof)}

${ext(p.evidence, '検証資料を見る')}
${source}
`; + return `

${c.statusKicker}

${c.statusTitle}

${rows.map(([h, items]) => `

${e(h)}

    ${items.map(i => `
  • ${e(i)}
  • `).join('')}
`).join('')}
${access}

${e(p[lang].proof)}

${ext(p.evidence, c.evidence)}
${source}
`; } export function refineOathraLp(html, products = ledgerProducts) { if (html.includes(`data-oathra-lp="${OATHRA_LP_VERSION}"`)) return html; - if (!//.test(html) || !html.includes('data-product-id="oathra"')) throw new Error('Oathra LP: not the Japanese Oathra page'); - const p = oathra(products), x = landingExperience.oathra.ja; + const lang = //.exec(html)?.[1]; + if (!lang || !html.includes('data-product-id="oathra"')) throw new Error('Oathra LP: not a Japanese or English Oathra page'); + const p = oathra(products, lang), x = landingExperience.oathra[lang], c = COPY[lang]; const mainStart = html.indexOf('
'), mainEnd = html.lastIndexOf('
'); if (mainStart < 0 || mainEnd < mainStart || html.indexOf('
', mainStart + 1) >= 0) throw new Error('Oathra LP: expected one
'); const main = html.slice(mainStart, mainEnd); @@ -139,19 +252,21 @@ export function refineOathraLp(html, products = ledgerProducts) { if (!prototypeLink) throw new Error('Oathra LP: expected the design-study link'); const startWithTry = start.replace('
', `${tryNow}
`); if (startWithTry === start) throw new Error('Oathra LP: expected the start actions'); - const composed = `
${hero(p, x)}${conceptDemosSection(p)}${ringSection()}${stepsSection(prototypeLink[0])}${delegationSection()}${recordingsSection(p, heroFilm, film15)}${statusSection(p, source, access.replace('
', '
').replace(`>${e(p.ja.license)} · `, '>'))}${startWithTry}${consult}${related}${footer}`; + const composed = `
${hero(p, x, c, lang)}${conceptDemosSection(p, lang)}${ringSection(c)}${stepsSection(prototypeLink[0], c, lang)}${delegationSection(c)}${recordingsSection(p, heroFilm, film15, c)}${statusSection(p, source, access.replace('
', '
').replace(`>${e(p[lang].license)} · `, '>'), c, lang)}${startWithTry}${consult}${related}${footer}`; let out = html.slice(0, mainStart) + composed + html.slice(mainEnd); out = out.replace('機能', 'href="#ring">しくみ').replace('href="#flow">流れ', 'href="#steps">流れ'); + for (const [from, to] of c.nav) out = out.replace(from, to); if ((out.match(/]/g) || []).length !== 1) throw new Error('Oathra LP: expected one h1'); return out; } export async function writeOathraLp(dist, products = ledgerProducts) { for (const asset of [FILM.src, FILM.poster, ...STEPS.map(s => s.src), ...oathra(products).conceptDemos.flatMap(demo => [demo.src, demo.poster])]) await fs.access(path.join(dist, asset)).catch(() => { throw new Error(`Oathra LP: missing ${asset}`); }); - const file = path.join(dist, PAGE); - await fs.writeFile(file, refineOathraLp(await fs.readFile(file, 'utf8'), products)); + for (const page of Object.values(PAGES)) { + const file = path.join(dist, page); + await fs.writeFile(file, refineOathraLp(await fs.readFile(file, 'utf8'), products)); + } const assets = path.join(dist, 'assets'); const [css, layer] = await Promise.all(['showcase.css', 'oathra-lp.css'].map(f => fs.readFile(path.join(assets, f), 'utf8'))); if (!layer.includes('[data-oathra-lp]')) throw new Error('Oathra LP stylesheet is missing or stale'); diff --git a/tests/fifteen-second-films.test.mjs b/tests/fifteen-second-films.test.mjs index f3356b6..a9239f3 100644 --- a/tests/fifteen-second-films.test.mjs +++ b/tests/fifteen-second-films.test.mjs @@ -11,16 +11,19 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const dist = path.join(root, 'dist'); const read = p => fs.readFile(path.join(dist, p), 'utf8'); -test('each film appears once, before its anchor, on its Japanese page only', async () => { +test('each film appears once, before its anchor, on its Japanese and English pages', async () => { for (const [id, f] of Object.entries(FILMS)) { - const html = await read(f.page); - assert.equal(html.split(`data-film15="${id}"`).length - 1, 1, id); - // The Japanese Oathra page is re-composed by src/oathra-lp.mjs (2026-09-29 brief); there the film sits in its - // recordings section. Elsewhere it still precedes the anchor it was inserted before. - if (html.includes('data-oathra-lp=')) assert.ok(html.indexOf('id="recordings"') < html.indexOf(`data-film15="${id}"`) && html.indexOf(`data-film15="${id}"`) < html.indexOf('id="status"'), `${id} sits in the recordings section`); - else assert.ok(html.indexOf(`data-film15="${id}"`) < html.indexOf(f.before), `${id} precedes its anchor`); - const en = await read(path.join('en', f.page)); - assert.doesNotMatch(en, /data-film15/, `${id}: the English page gets no Japanese film`); + assert.equal(f.en.page, path.join('en', f.page)); + for (const page of [f.page, f.en.page]) { + const html = await read(page); + assert.equal(html.split(`data-film15="${id}"`).length - 1, 1, `${id} ${page}`); + // The Oathra pages are re-composed by src/oathra-lp.mjs (2026-09-29 brief); there the film sits in its + // recordings section. Elsewhere it still precedes the anchor it was inserted before. + if (html.includes('data-oathra-lp=')) assert.ok(html.indexOf('id="recordings"') < html.indexOf(`data-film15="${id}"`) && html.indexOf(`data-film15="${id}"`) < html.indexOf('id="status"'), `${id} sits in the recordings section of ${page}`); + else assert.ok(html.indexOf(`data-film15="${id}"`) < html.indexOf(f.before), `${id} precedes its anchor on ${page}`); + } + // The films carry Japanese on-screen text (Oathra: Japanese audio); the English placement says so. + assert.match(f.en.meta, /Japanese/); } // The v4 home links the Genie and Oathra films from their chapters, labelled as edited films. const home = await read('index.html'); diff --git a/tests/genie-lp.test.mjs b/tests/genie-lp.test.mjs index a392402..4f57ec0 100644 --- a/tests/genie-lp.test.mjs +++ b/tests/genie-lp.test.mjs @@ -19,7 +19,7 @@ test('Genie LP composes once, keeps its anchors in order and fails closed', () = assert.ok(at('id="features"') < at('id="demos"') && at('id="demos"') < at('id="artifacts"') && at('id="artifacts"') < at('data-film15="genie"')); assert.ok(at('data-film15="genie"') < at('id="flow"') && at('id="flow"') < at('id="trust"') && at('id="trust"') < at('id="start"')); assert.match(once, /まずは、
手元のメモから。/); - assert.throws(() => refineGenieLp(shell.replace('lang="ja"', 'lang="en"'), products), /Japanese Genie/); + assert.throws(() => refineGenieLp(shell.replace('lang="ja"', 'lang="fr"'), products), /Japanese or English Genie/); assert.throws(() => refineGenieLp(shell.replace('主要機能・できること', 'x'), products), /features heading/); assert.throws(() => refineGenieLp(shell.replace('data-recording-src="/media/products/genie.mp4"', ''), products), /top recording/); assert.throws(() => refineGenieLp(shell.replace('data-film15="genie"', '').replace('rm-film15--genie', 'x'), products), /15-second film/); @@ -83,10 +83,18 @@ test('01 shows one work screen where only the next TaskDock changes, labelled as assert.ok(html.indexOf('data-glp-return') < html.indexOf('05 · START'), 'the closing line returns to the mark before the start heading'); }); -test('built Genie page is the LP in Japanese only, with its assets present', async () => { +test('built Genie pages are the LP in Japanese and English, with their assets present', async () => { const ja = await read('products/genie/index.html'), en = await read('en/products/genie/index.html'); assert.match(ja, new RegExp(`data-genie-lp="${GENIE_LP_VERSION}"`)); - assert.doesNotMatch(en, /data-genie-lp/); + assert.match(en, new RegExp(`data-genie-lp="${GENIE_LP_VERSION}"`)); + // English follows the Japanese sections one for one and keeps the same evidence labels. + for (const id of ['id="demos"', 'id="artifacts"', 'data-film15="genie"', 'id="trust"', 'data-glp-how']) assert.ok(en.includes(id), id); + assert.match(en, /In the middle of work,
call up<\/span> Genie\./); + assert.match(en, /Screens cut from a public demonstration of the real app \(Japanese UI\)\. The input is a fictional example\./); + assert.match(en, /the final version was made with Codex/); + assert.match(en, /It is not a recording of the real app\./); + assert.match(en, /
/, 'the saved Japanese Markdown is marked as Japanese'); + assert.doesNotMatch(en.slice(en.indexOf('id="demos"'), en.indexOf('id="artifacts"')), /依頼<\/span>|拡大して見る/); for (const f of Object.values(FRAMES)) await fs.access(path.join(root, 'dist', f.src)); const css = await read('assets/showcase.css'), js = await read('assets/showcase.mjs'); assert.equal(css.split('/* REACHMADE_GENIE_LP */').length - 1, 1); diff --git a/tests/oathra-concept-demos.test.mjs b/tests/oathra-concept-demos.test.mjs index cee7e4d..78b1119 100644 --- a/tests/oathra-concept-demos.test.mjs +++ b/tests/oathra-concept-demos.test.mjs @@ -38,8 +38,15 @@ test('three fictional use cases remain usable as links and native players withou product.conceptDemos[index].steps.forEach(step => assert.ok(caption.includes(step))); assert.ok(caption.includes(product.conceptDemos[index].result), 'the silent film has a text result'); }); + // The English page explains the same three demos in English and says their UI and subtitles are Japanese. const en = await fs.readFile(path.join(root, 'dist/en/products/oathra/index.html'), 'utf8'); - assert.ok(!en.includes('id="concept-demos"'), 'Japanese recordings are added only to the requested Japanese LP'); + const enSection = en.slice(en.indexOf('id="concept-demos"'), en.indexOf('id="ring"')); + assert.ok(en.includes('id="concept-demos"'), 'the English LP carries the concept demos'); + assert.match(enSection, /Concept demo · no real calls or bookings/); + assert.match(enSection, /\(Japanese UI\)/); + assert.equal((enSection.match(/Japanese subtitles · silent/g) || []).length, 6, 'each film says its subtitles are Japanese (label + caption)'); + for (const demo of product.conceptDemos) assert.ok(enSection.includes(`href="${demo.href}"`) && enSection.includes(``), demo.id); + assert.doesNotMatch(enSection, /操作の流れ|動画の結果/); assert.throws(() => conceptDemosSection({...product, conceptDemos:[]}), /expected three concept demos/); }); diff --git a/tests/oathra-lp.test.mjs b/tests/oathra-lp.test.mjs index a8ce517..8c8fd91 100644 --- a/tests/oathra-lp.test.mjs +++ b/tests/oathra-lp.test.mjs @@ -51,15 +51,25 @@ test('the Japanese Oathra page follows the brief order and states the status of assert.doesNotMatch(html, /href="#features"|href="#flow"/, 'header links follow the new sections'); }); -test('English Oathra and the other product pages are not re-composed', async () => { - assert.doesNotMatch(await read('en/products/oathra/index.html'), /data-oathra-lp/); +test('English Oathra follows the Japanese composition; other product pages are not re-composed', async () => { + const en = await read('en/products/oathra/index.html'); + assert.match(en, /data-oathra-lp=/); + const order = ['id="concept-demos"', 'id="ring"', 'id="steps"', 'id="trust"', 'id="recordings"', 'id="status"', 'id="start"'].map(s => en.indexOf(s)); + assert.ok(order.every((at, i) => at > 0 && (i === 0 || at > order[i - 1])), 'English sections in the brief order'); + // The redesign keeps this first view below its own title, so the heading becomes an h2 (as on the Japanese page). + assert.match(en, /“Done” is not
evidence\.<\/h[12]>/); + assert.match(en, /Entry into a shop’s booking system/); + assert.match(en, /Evaluation with 100 real calls/); + assert.match(en, /Design · not in the real-call flow/); + assert.match(en, /Screens in development · not released/); + assert.doesNotMatch(en, /href="#features"|href="#flow"/, 'header links follow the new sections'); for (const id of ['genie', 'ai-meeting', 'aisecure', 'agent-team', 'launchloom', 'noa']) assert.doesNotMatch(await read(`products/${id}/index.html`), /data-oathra-lp/); }); test('the pass is idempotent and fails closed', async () => { const html = await read('products/oathra/index.html'); assert.equal(refineOathraLp(html, products), html); - assert.throws(() => refineOathraLp(html.replace('data-oathra-lp=', 'data-x=').replace('', ''), products), /not the Japanese Oathra page/); + assert.throws(() => refineOathraLp(html.replace('data-oathra-lp=', 'data-x=').replace('', ''), products), /not a Japanese or English Oathra page/); const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'oathra-lp-')); try { await assert.rejects(writeOathraLp(tmp, products), /missing \/media\/films\/oathra-30s\.mp4/); } finally { await fs.rm(tmp, { recursive: true, force: true }); } diff --git a/tests/product-art-direction.test.mjs b/tests/product-art-direction.test.mjs index a44b9f9..a687895 100644 --- a/tests/product-art-direction.test.mjs +++ b/tests/product-art-direction.test.mjs @@ -27,16 +27,16 @@ for(const [id,direction] of Object.entries(directions))for(const lang of ['ja',' }); test(`integration ${id}/${lang}: final built page contains the active composition`,async()=>{ const html=await fs.readFile(path.join(root,'dist',lang==='en'?'en':'','products',id,'index.html'),'utf8'); - // The Japanese Oathra page uses the 2026-09-29 brief's first view instead of the art-directed hero. - if(id==='oathra'&&lang==='ja') assert.match(html,/data-oathra-lp="/); + // The Oathra pages (Japanese, and English since 2026-10-19) use the 2026-09-29 brief's first view instead of the art-directed hero. + if(id==='oathra') assert.match(html,/data-oathra-lp="/); // The Japanese 星藍ノア page is re-composed from the owner's patch; its film is the introduction film (tests/noa.test.mjs). else if(id==='noa'&&lang==='ja') { assert.match(html,/data-noa-lp="/); return; } else assert.match(html,new RegExp(`data-direction="${direction}"`)); assert.match(html,/data-art-direction="20260919"/); assert.equal((html.match(/{ diff --git a/tests/product-landings.test.mjs b/tests/product-landings.test.mjs index f762876..0877bfb 100644 --- a/tests/product-landings.test.mjs +++ b/tests/product-landings.test.mjs @@ -21,15 +21,15 @@ for (const id of landingIds) for (const lang of ['ja','en']) { if (id==='noa' && lang==='ja') { assert.match(await fs.readFile(path.join(root,'dist',route,'index.html'),'utf8'),/data-noa-lp="/); return; } const html = await fs.readFile(path.join(root,'dist',route,'index.html'),'utf8'); assert.match(html,new RegExp(`data-product-id="${id}"`)); - // The Japanese Genie page opens with the 01 TaskDock reconstruction (owner's choice, 2026-09-30), labelled as such. - const film = id==='genie' && lang==='ja' ? {src:'/media/films/genie-taskdock-13s.mp4',poster:'/media/films/genie-taskdock-13s.jpg'} : {src:`/media/products/${id}.mp4`,poster:`/media/products/${id}.jpg`}; + // The Genie pages open with the 01 TaskDock reconstruction (owner's choice, 2026-09-30; English since 2026-10-19), labelled as such. + const film = id==='genie' ? {src:'/media/films/genie-taskdock-13s.mp4',poster:'/media/films/genie-taskdock-13s.jpg'} : {src:`/media/products/${id}.mp4`,poster:`/media/products/${id}.jpg`}; assert.ok(html.includes(`data-recording-src="${film.src}"`), `${id}/${lang} top film`); assert.ok(html.includes(`poster="${film.poster}"`), `${id}/${lang} poster`); - if (id==='genie' && lang==='ja') assert.match(html,/再現映像 · 開発中の次の版[\s\S]*実アプリの録画ではありません。/); + if (id==='genie') assert.match(html, lang==='ja' ? /再現映像 · 開発中の次の版[\s\S]*実アプリの録画ではありません。/ : /Reconstruction · next version in development[\s\S]*It is not a recording of the real app\./); assert.doesNotMatch(html,/]*\ssrc=|]*\bautoplay\b|