diff --git a/.claude/rules/scratch-gui/smalruby-markers.md b/.claude/rules/scratch-gui/smalruby-markers.md
index cbad04806cd..0115643f09a 100644
--- a/.claude/rules/scratch-gui/smalruby-markers.md
+++ b/.claude/rules/scratch-gui/smalruby-markers.md
@@ -45,7 +45,9 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一
| `src/components/menu-bar/menu-bar.jsx` | smalrubot firmware menu | SmalrubotS1 メニューの import、ハンドラー、レンダリング、Redux 接続 |
| `src/components/gui/gui.jsx` | smalrubot firmware modal | SmalrubotS1 ファームウェアモーダルの import と配置 |
| `src/components/gui/gui.jsx` | classroom modal | クラスルームモーダルの import と配置 |
+| `src/components/gui/gui.jsx` | narrow screen warning | 狭幅画面警告バナーの import と配置 (issue #572 Phase 1) |
| `src/components/gui/gui.jsx` | Redux action props prevention | Redux action props の伝播防止 |
+| `src/playground/index.css` | narrow viewport vertical scroll lock | 狭幅画面で縦スクロールを overflow-y: clip で抑止 (issue #572 Phase 1) |
| `src/containers/gui.jsx` | smalrubot firmware modal | SmalrubotS1 ファームウェアモーダル state マッピング |
| `src/containers/gui.jsx` | classroom modal | クラスルームモーダル state マッピング |
| `src/containers/gui.jsx` | classcode auto-open | クラスコード URL パラメーターによるモーダル自動オープン |
diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md
index 83ee66b7978..1968d7d85cf 100644
--- a/.claude/rules/scratch-gui/smalruby-prettier-files.md
+++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md
@@ -21,6 +21,7 @@ upstream (Scratch) ファイルは対象外。
- `src/components/blocks-screenshot-button/`
- `src/components/google-drive-save-dialog/`
- `src/components/koshien-test-modal/`
+- `src/components/narrow-screen-warning/`
- `src/components/palette-toggle/`
- `src/components/ruby-script-preview/`
- `src/components/ruby-toolbar/`
@@ -181,6 +182,7 @@ upstream (Scratch) ファイルは対象外。
- `test/integration/version-update-notification.test.js`
- `test/unit/components/action-menu.test.jsx`
- `test/unit/components/connected-step.test.jsx`
+- `test/unit/components/narrow-screen-warning.test.jsx`
- `test/unit/components/palette-toggle.test.jsx`
- `test/unit/components/project-title-input.test.jsx`
- `test/unit/components/scanning-step-name-search.test.js`
diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore
index b8291cf86b0..b29a4cf5257 100644
--- a/packages/scratch-gui/.prettierignore
+++ b/packages/scratch-gui/.prettierignore
@@ -38,6 +38,7 @@ src/components/*
!src/components/blocks-screenshot-button/
!src/components/google-drive-save-dialog/
!src/components/koshien-test-modal/
+!src/components/narrow-screen-warning/
!src/components/palette-toggle/
!src/components/ruby-script-preview/
!src/components/ruby-toolbar/
@@ -244,6 +245,7 @@ test/unit/*
test/unit/components/*
!test/unit/components/action-menu.test.jsx
!test/unit/components/connected-step.test.jsx
+!test/unit/components/narrow-screen-warning.test.jsx
!test/unit/components/palette-toggle.test.jsx
!test/unit/components/project-title-input.test.jsx
!test/unit/components/scanning-step-name-search.test.js
diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx
index bb50118ef1e..9250c34f490 100644
--- a/packages/scratch-gui/src/components/gui/gui.jsx
+++ b/packages/scratch-gui/src/components/gui/gui.jsx
@@ -38,6 +38,9 @@ import SmalrubotFirmwareModal from '../../containers/smalrubot-firmware-modal.js
// === Smalruby: Start of classroom modal ===
import ClassroomModal from '../../containers/classroom-modal.jsx';
// === Smalruby: End of classroom modal ===
+// === Smalruby: Start of narrow screen warning ===
+import NarrowScreenWarning from '../narrow-screen-warning/narrow-screen-warning.jsx';
+// === Smalruby: End of narrow screen warning ===
import URLLoaderModal from '../url-loader-modal/url-loader-modal.jsx';
import KoshienTestModal from '../koshien-test-modal/koshien-test-modal.jsx';
import RubyTab from '../../containers/ruby-tab.jsx';
@@ -420,6 +423,9 @@ const GUIComponent = props => {
) : null}
{/* === Smalruby: End of classroom modal === */}
{/* === Smalruby: End of smalrubot firmware modal === */}
+ {/* === Smalruby: Start of narrow screen warning === */}
+
+ {/* === Smalruby: End of narrow screen warning === */}
{!menuBarHidden && {
+ const [isNarrow, setIsNarrow] = useState(() => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return false;
+ }
+ return window.matchMedia(NARROW_SCREEN_QUERY).matches;
+ });
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return () => {};
+ }
+ const mql = window.matchMedia(NARROW_SCREEN_QUERY);
+ const handler = event => setIsNarrow(event.matches);
+ if (typeof mql.addEventListener === 'function') {
+ mql.addEventListener('change', handler);
+ return () => mql.removeEventListener('change', handler);
+ }
+ // Safari < 14 fallback
+ mql.addListener(handler);
+ return () => mql.removeListener(handler);
+ }, []);
+
+ return isNarrow;
+};
+
+/**
+ * バナーを visual viewport の下端に追従させる (position: fixed 前提)。
+ * fixed の containing block は layout viewport なので、visual viewport との
+ * オフセット (visualViewport.offsetTop / offsetLeft) を加味して位置決めする。
+ * 縦スクロール領域には影響しない (fixed なので body の overflow に乗らない)。
+ * @param {object} ref - バナー root への React ref
+ * @param {boolean} enabled - 位置追従を有効化するか
+ */
+const useVisualViewportPosition = (ref, enabled) => {
+ useLayoutEffect(() => {
+ if (!enabled || !ref.current) return () => {};
+ if (typeof window === 'undefined') return () => {};
+ const el = ref.current;
+ const vv = window.visualViewport;
+
+ const update = () => {
+ const height = el.offsetHeight;
+ if (vv) {
+ // fixed コンテキストなので layout viewport 基準の座標で指定
+ el.style.top = `${vv.offsetTop + vv.height - height}px`;
+ el.style.left = `${vv.offsetLeft}px`;
+ el.style.width = `${vv.width}px`;
+ } else {
+ el.style.top = `${window.innerHeight - height}px`;
+ el.style.left = '0px';
+ el.style.width = `${window.innerWidth}px`;
+ }
+ };
+
+ update();
+ const targets = vv ? [vv, window] : [window];
+ const events = ['resize', 'scroll'];
+ for (const t of targets) {
+ for (const ev of events) {
+ t.addEventListener(ev, update, { passive: true });
+ }
+ }
+ return () => {
+ for (const t of targets) {
+ for (const ev of events) {
+ t.removeEventListener(ev, update);
+ }
+ }
+ };
+ }, [enabled, ref]);
+};
+
+const NarrowScreenWarning = () => {
+ const isNarrow = useIsNarrowScreen();
+ // 永続化はしない (issue #572 Phase 1 では狭幅利用は鑑賞用途として
+ // 案内し続ける)。dismiss は現在の画面表示のみで、リロードや再訪では再表示。
+ const [dismissed, setDismissed] = useState(false);
+ const ref = useRef(null);
+ const visible = isNarrow && !dismissed;
+ useVisualViewportPosition(ref, visible);
+
+ const handleClose = useCallback(() => {
+ setDismissed(true);
+ }, []);
+
+ if (!visible) {
+ return null;
+ }
+
+ if (typeof document === 'undefined') {
+ return null;
+ }
+
+ // body 直下に Portal で飛ばし、JS で visualViewport 座標へ位置決め。
+ return createPortal(
+ ,
+ document.body,
+ );
+};
+
+export default NarrowScreenWarning;
diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js
index bdda0c767f2..e458cc2dbb7 100644
--- a/packages/scratch-gui/src/locales/en.js
+++ b/packages/scratch-gui/src/locales/en.js
@@ -635,4 +635,6 @@ export default {
'gui.extensionButton.dnclExtensionDisabled': 'Extensions are not available in Japanese mode.',
'gui.rubyTab.dnclValidationError':
'This code contains constructs not supported in Japanese mode.\nPlease use only supported instructions before switching modes.',
+ 'gui.narrowScreenWarning.message': '📱 For full editing, a PC or tablet is recommended.',
+ 'gui.narrowScreenWarning.close': 'Close',
};
diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js
index 457fb5aa167..7694f437c9f 100644
--- a/packages/scratch-gui/src/locales/ja-Hira.js
+++ b/packages/scratch-gui/src/locales/ja-Hira.js
@@ -941,4 +941,6 @@ export default {
'gui.extensionButton.dnclExtensionDisabled': 'にほんごモードではかくちょうきのうはつかえません。',
'gui.rubyTab.dnclValidationError':
'にほんごモードではたいおうしていないきじゅつです。\nたいおうしているめいれいのみにしてから、モードきりかえをおこなってください。',
+ 'gui.narrowScreenWarning.message': '📱 ほんかくてきな へんしゅうは PC・タブレットが おすすめです。',
+ 'gui.narrowScreenWarning.close': 'とじる',
};
diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js
index c9ca5430f1c..d7f15c728d9 100644
--- a/packages/scratch-gui/src/locales/ja.js
+++ b/packages/scratch-gui/src/locales/ja.js
@@ -916,4 +916,6 @@ export default {
'gui.extensionButton.dnclExtensionDisabled': '日本語モードでは拡張機能は使えません。',
'gui.rubyTab.dnclValidationError':
'日本語モードでは対応していない記述です。\n対応している命令のみにしてから、モード切り替えを行ってください。',
+ 'gui.narrowScreenWarning.message': '📱 本格的な編集は PC・タブレットを推奨します。',
+ 'gui.narrowScreenWarning.close': '閉じる',
};
diff --git a/packages/scratch-gui/src/playground/index.css b/packages/scratch-gui/src/playground/index.css
index 4b65314a001..7c2cfe1bcb1 100644
--- a/packages/scratch-gui/src/playground/index.css
+++ b/packages/scratch-gui/src/playground/index.css
@@ -2,7 +2,7 @@ html,
body,
.app {
/* probably unecessary, transitional until layout is refactored */
- width: 100%;
+ width: 100%;
height: 100%;
margin: 0;
@@ -11,5 +11,18 @@ body,
min-height: 600px; /* Min height to fit sprite/backdrop button */
}
+/* === Smalruby: Start of narrow viewport vertical scroll lock === */
+/* iPhone 等の狭幅画面で、min-height: 600px と GUI 内部要素の縦方向に */
+/* よって body が viewport より縦に伸びてしまい、ページ全体が縦スクロール */
+/* 可能になっていた。横スクロール (1024px min-width) は残したいので、 */
+/* overflow-y: clip だけ当てる (clip は overflow-x への副作用がない)。 */
+@media (max-width: 767px) {
+ html,
+ body {
+ overflow-y: clip;
+ }
+}
+/* === Smalruby: End of narrow viewport vertical scroll lock === */
+
/* @todo: move globally? Safe / side FX, for blocks particularly? */
* { box-sizing: border-box; }
diff --git a/packages/scratch-gui/test/unit/components/narrow-screen-warning.test.jsx b/packages/scratch-gui/test/unit/components/narrow-screen-warning.test.jsx
new file mode 100644
index 00000000000..25f9a49c285
--- /dev/null
+++ b/packages/scratch-gui/test/unit/components/narrow-screen-warning.test.jsx
@@ -0,0 +1,71 @@
+/* eslint-env jest */
+import React from 'react';
+import { IntlProvider } from 'react-intl';
+import '@testing-library/jest-dom';
+import { act, fireEvent, render } from '@testing-library/react';
+import NarrowScreenWarning from '../../../src/components/narrow-screen-warning/narrow-screen-warning.jsx';
+
+const renderWithIntl = ui =>
+ render(
+
+ {ui}
+ ,
+ );
+
+const setMatchMedia = matches => {
+ const listeners = new Set();
+ const mql = {
+ matches,
+ media: '',
+ onchange: null,
+ addEventListener: (_event, listener) => listeners.add(listener),
+ removeEventListener: (_event, listener) => listeners.delete(listener),
+ addListener: listener => listeners.add(listener),
+ removeListener: listener => listeners.delete(listener),
+ dispatchEvent: () => false,
+ };
+ window.matchMedia = jest.fn(() => mql);
+ return { mql, listeners };
+};
+
+describe('NarrowScreenWarning', () => {
+ test('renders banner when viewport is narrow', () => {
+ setMatchMedia(true);
+ const { queryByTestId } = renderWithIntl();
+ expect(queryByTestId('narrow-screen-warning')).toBeInTheDocument();
+ });
+
+ test('renders nothing when viewport is wide', () => {
+ setMatchMedia(false);
+ const { queryByTestId } = renderWithIntl();
+ expect(queryByTestId('narrow-screen-warning')).not.toBeInTheDocument();
+ });
+
+ test('hides banner for the current render after close is clicked', () => {
+ setMatchMedia(true);
+ const { queryByTestId, getByTestId } = renderWithIntl();
+ fireEvent.click(getByTestId('narrow-screen-warning-close'));
+ expect(queryByTestId('narrow-screen-warning')).not.toBeInTheDocument();
+ });
+
+ test('shows banner again on a fresh render even after a previous close', () => {
+ setMatchMedia(true);
+ // 1st render: close it
+ const first = renderWithIntl();
+ fireEvent.click(first.getByTestId('narrow-screen-warning-close'));
+ first.unmount();
+ // 2nd render (e.g. reload simulation): banner reappears
+ const second = renderWithIntl();
+ expect(second.queryByTestId('narrow-screen-warning')).toBeInTheDocument();
+ });
+
+ test('appears when viewport shrinks below the breakpoint', () => {
+ const { listeners } = setMatchMedia(false);
+ const { queryByTestId } = renderWithIntl();
+ expect(queryByTestId('narrow-screen-warning')).not.toBeInTheDocument();
+ act(() => {
+ listeners.forEach(listener => listener({ matches: true }));
+ });
+ expect(queryByTestId('narrow-screen-warning')).toBeInTheDocument();
+ });
+});