From 40e538bb47a538aa7890878e69222e8e00f46b1d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Fri, 22 May 2026 21:42:15 +0900 Subject: [PATCH 01/10] feat(dncl): add exitDnclModeExternallyRequested redux signal Add `exitDnclModeExternallyRequested` state and two new actions (`requestExternalExitDnclMode` / `clearExternalExitDnclModeRequest`) to dncl-mode.js so that external components (e.g. ExtensionButton) can signal ruby-tab to exit DNCL mode without directly calling `handleToggleDnclMode`. Closes part of #701. Co-Authored-By: Claude Sonnet 4.6 --- .../scratch-gui/src/reducers/dncl-mode.js | 28 +++++++- .../unit/reducers/dncl-mode-reducer.test.js | 70 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js diff --git a/packages/scratch-gui/src/reducers/dncl-mode.js b/packages/scratch-gui/src/reducers/dncl-mode.js index b61165a9774..638aea71b30 100644 --- a/packages/scratch-gui/src/reducers/dncl-mode.js +++ b/packages/scratch-gui/src/reducers/dncl-mode.js @@ -2,6 +2,8 @@ import { getUrlParams } from '../lib/url-params'; const SET_DNCL_MODE = 'scratch-gui/dncl-mode/SET_DNCL_MODE'; +const REQUEST_EXTERNAL_EXIT_DNCL_MODE = 'scratch-gui/dncl-mode/REQUEST_EXTERNAL_EXIT_DNCL_MODE'; +const CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST = 'scratch-gui/dncl-mode/CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST'; const DNCL_MODE_KEY = 'smalruby:dnclMode'; @@ -22,6 +24,7 @@ const getInitialDnclMode = () => { const initialState = { dnclMode: getInitialDnclMode(), + exitDnclModeExternallyRequested: false, }; const reducer = function (state, action) { @@ -34,6 +37,14 @@ const reducer = function (state, action) { return Object.assign({}, state, { dnclMode: action.dnclMode, }); + case REQUEST_EXTERNAL_EXIT_DNCL_MODE: + return Object.assign({}, state, { + exitDnclModeExternallyRequested: true, + }); + case CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST: + return Object.assign({}, state, { + exitDnclModeExternallyRequested: false, + }); default: return state; } @@ -46,4 +57,19 @@ const setDnclMode = function (dnclMode) { }; }; -export { reducer as default, initialState as dnclModeInitialState, setDnclMode, SET_DNCL_MODE }; +const requestExternalExitDnclMode = function () { + return { type: REQUEST_EXTERNAL_EXIT_DNCL_MODE }; +}; + +const clearExternalExitDnclModeRequest = function () { + return { type: CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST }; +}; + +export { + reducer as default, + initialState as dnclModeInitialState, + setDnclMode, + SET_DNCL_MODE, + requestExternalExitDnclMode, + clearExternalExitDnclModeRequest, +}; diff --git a/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js b/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js new file mode 100644 index 00000000000..25161836249 --- /dev/null +++ b/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js @@ -0,0 +1,70 @@ +import reducer, { + dnclModeInitialState, + setDnclMode, + requestExternalExitDnclMode, + clearExternalExitDnclModeRequest, +} from '../../../src/reducers/dncl-mode'; + +describe('dncl-mode reducer', () => { + describe('initial state', () => { + test('should have dnclMode as false by default', () => { + expect(dnclModeInitialState.dnclMode).toBe(false); + }); + + test('should have exitDnclModeExternallyRequested as false', () => { + expect(dnclModeInitialState.exitDnclModeExternallyRequested).toBe(false); + }); + }); + + describe('setDnclMode', () => { + test('should set dnclMode to true', () => { + const state = reducer(dnclModeInitialState, setDnclMode(true)); + expect(state.dnclMode).toBe(true); + }); + + test('should set dnclMode to false', () => { + const state = reducer({ ...dnclModeInitialState, dnclMode: true }, setDnclMode(false)); + expect(state.dnclMode).toBe(false); + }); + + test('should not affect exitDnclModeExternallyRequested', () => { + const state = reducer(dnclModeInitialState, setDnclMode(true)); + expect(state.exitDnclModeExternallyRequested).toBe(false); + }); + }); + + describe('requestExternalExitDnclMode', () => { + test('should set exitDnclModeExternallyRequested to true', () => { + const state = reducer(dnclModeInitialState, requestExternalExitDnclMode()); + expect(state.exitDnclModeExternallyRequested).toBe(true); + }); + + test('should not affect dnclMode', () => { + const state = reducer({ ...dnclModeInitialState, dnclMode: true }, requestExternalExitDnclMode()); + expect(state.dnclMode).toBe(true); + }); + }); + + describe('clearExternalExitDnclModeRequest', () => { + test('should reset exitDnclModeExternallyRequested to false', () => { + const active = reducer(dnclModeInitialState, requestExternalExitDnclMode()); + expect(active.exitDnclModeExternallyRequested).toBe(true); + + const cleared = reducer(active, clearExternalExitDnclModeRequest()); + expect(cleared.exitDnclModeExternallyRequested).toBe(false); + }); + + test('should not affect dnclMode', () => { + const active = { ...dnclModeInitialState, dnclMode: true, exitDnclModeExternallyRequested: true }; + const state = reducer(active, clearExternalExitDnclModeRequest()); + expect(state.dnclMode).toBe(true); + }); + }); + + describe('unknown action', () => { + test('should return state unchanged', () => { + const state = reducer(dnclModeInitialState, { type: 'UNKNOWN' }); + expect(state).toEqual(dnclModeInitialState); + }); + }); +}); From 866f7808dac004e8e866d6dede639df898f32711 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Fri, 22 May 2026 21:53:31 +0900 Subject: [PATCH 02/10] feat(extension-button): replace alert with confirm and add exit-dncl callback Replace window.alert with window.confirm in DNCL mode. When the user confirms, dispatch onRequestExitDnclMode (Redux signal) and open the extension library. Cancel leaves the mode unchanged. Refs #701. Co-Authored-By: Claude Sonnet 4.6 --- .../extension-button/extension-button.jsx | 30 +++--- .../components/extension-button-dncl.test.jsx | 91 +++++++++++++++++++ 2 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx diff --git a/packages/scratch-gui/src/components/extension-button/extension-button.jsx b/packages/scratch-gui/src/components/extension-button/extension-button.jsx index 0293bdf73da..2897419e6c3 100644 --- a/packages/scratch-gui/src/components/extension-button/extension-button.jsx +++ b/packages/scratch-gui/src/components/extension-button/extension-button.jsx @@ -15,33 +15,38 @@ const messages = defineMessages({ description: 'Button to add an extension in the target pane', defaultMessage: 'Add Extension' }, - // === Smalruby: Start of DNCL extension alert === - dnclExtensionDisabled: { - id: 'gui.extensionButton.dnclExtensionDisabled', - description: 'Alert message when extension button is clicked in DNCL mode', - defaultMessage: 'Extensions are not available in Japanese mode.' + // === Smalruby: Start of DNCL extension confirm === + dnclExtensionConfirm: { + id: 'gui.extensionButton.dnclExtensionConfirm', + description: 'Confirm dialog when extension button is clicked in DNCL mode', + defaultMessage: 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?' } - // === Smalruby: End of DNCL extension alert === + // === Smalruby: End of DNCL extension confirm === }); const ExtensionButton = props => { const { intl, dnclMode, - onExtensionButtonClick + onExtensionButtonClick, + onRequestExitDnclMode } = props; const {captureFocus} = useContext(ModalFocusContext); const handleExtensionButtonClick = useCallback(() => { - // === Smalruby: Start of DNCL extension alert === + // === Smalruby: Start of DNCL extension confirm === if (dnclMode) { - window.alert(intl.formatMessage(messages.dnclExtensionDisabled)); // eslint-disable-line no-alert + // eslint-disable-next-line no-alert + const confirmed = window.confirm(intl.formatMessage(messages.dnclExtensionConfirm)); + if (!confirmed) return; + onRequestExitDnclMode?.(); + onExtensionButtonClick?.(); return; } - // === Smalruby: End of DNCL extension alert === + // === Smalruby: End of DNCL extension confirm === captureFocus(); onExtensionButtonClick?.(); - }, [captureFocus, onExtensionButtonClick, dnclMode, intl]); + }, [captureFocus, onExtensionButtonClick, onRequestExitDnclMode, dnclMode, intl]); return ( @@ -68,7 +73,8 @@ const ExtensionButton = props => { ExtensionButton.propTypes = { dnclMode: PropTypes.bool, intl: intlShape.isRequired, - onExtensionButtonClick: PropTypes.func + onExtensionButtonClick: PropTypes.func, + onRequestExitDnclMode: PropTypes.func }; const ExtensionButtonIntl = injectIntl(ExtensionButton); diff --git a/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx b/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx new file mode 100644 index 00000000000..7380a7d55d8 --- /dev/null +++ b/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx @@ -0,0 +1,91 @@ +/* eslint-env jest */ +import '@testing-library/jest-dom'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import { ModalFocusContext } from '../../../src/contexts/modal-focus-context.jsx'; +import ExtensionButton from '../../../src/components/extension-button/extension-button.jsx'; + +const mockFocusContext = { captureFocus: jest.fn(), restoreFocus: jest.fn() }; + +const renderButton = (props) => + render( + + + + + , + ); + +describe('ExtensionButton in DNCL mode', () => { + let onExtensionButtonClick; + let onRequestExitDnclMode; + + beforeEach(() => { + onExtensionButtonClick = jest.fn(); + onRequestExitDnclMode = jest.fn(); + jest.spyOn(window, 'confirm').mockReturnValue(false); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('shows confirm dialog (not alert) when clicked in DNCL mode', () => { + const { getByTestId } = renderButton({ + dnclMode: true, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(window.alert).not.toHaveBeenCalled(); + }); + + test('does not open extension library when confirm is cancelled', () => { + window.confirm.mockReturnValue(false); + const { getByTestId } = renderButton({ + dnclMode: true, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(onExtensionButtonClick).not.toHaveBeenCalled(); + expect(onRequestExitDnclMode).not.toHaveBeenCalled(); + }); + + test('dispatches exit request and opens extension library when confirm is accepted', () => { + window.confirm.mockReturnValue(true); + const { getByTestId } = renderButton({ + dnclMode: true, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(onRequestExitDnclMode).toHaveBeenCalledTimes(1); + expect(onExtensionButtonClick).toHaveBeenCalledTimes(1); + }); + + test('opens extension library directly when not in DNCL mode', () => { + const { getByTestId } = renderButton({ + dnclMode: false, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(window.confirm).not.toHaveBeenCalled(); + expect(onExtensionButtonClick).toHaveBeenCalledTimes(1); + }); + + test('does not call confirm or open library when dnclMode is undefined', () => { + const { getByTestId } = renderButton({ + dnclMode: undefined, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(window.confirm).not.toHaveBeenCalled(); + expect(onExtensionButtonClick).toHaveBeenCalledTimes(1); + }); +}); From bdfdad6ea422e7498da1cadc8a81112307e1f43d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 23 May 2026 00:15:05 +0900 Subject: [PATCH 03/10] feat(dncl): add DnclModeNotice banner and wire exit-dncl to gui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DnclModeNotice component (blocks tab warning banner with "Rubyふりがなモードに戻す" button and confirm dialog) - Wire onRequestExitDnclMode callback: dispatches setDnclMode(false) and requestExternalExitDnclMode to gui container - Pass onRequestExitDnclMode to ExtensionButton and DnclModeNotice in gui.jsx - Update .prettierignore and smalruby-prettier-files.md for new files Refs #701. Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/scratch-gui/smalruby-markers.md | 2 + .../scratch-gui/smalruby-prettier-files.md | 4 ++ packages/scratch-gui/.prettierignore | 4 ++ .../dncl-mode-notice/dncl-mode-notice.css | 34 +++++++++++ .../dncl-mode-notice/dncl-mode-notice.jsx | 55 +++++++++++++++++ .../scratch-gui/src/components/gui/gui.jsx | 12 ++++ packages/scratch-gui/src/containers/gui.jsx | 10 +++ .../unit/components/dncl-mode-notice.test.jsx | 61 +++++++++++++++++++ .../components/extension-button-dncl.test.jsx | 2 +- 9 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css create mode 100644 packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx create mode 100644 packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx diff --git a/.claude/rules/scratch-gui/smalruby-markers.md b/.claude/rules/scratch-gui/smalruby-markers.md index 8dde1b5daef..0f88223624c 100644 --- a/.claude/rules/scratch-gui/smalruby-markers.md +++ b/.claude/rules/scratch-gui/smalruby-markers.md @@ -48,6 +48,8 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一 | `src/components/gui/gui.jsx` | meshV2 classroom binding | クラス状態に応じて Mesh v2 ドメインを参加コードに固定する常時マウントの binding | | `src/components/gui/gui.jsx` | welcome modal | 初回訪問者向けウェルカムモーダル HOC の import と配置、`onShowWelcomeModal` prop | | `src/containers/gui.jsx` | welcome modal | `onShowWelcomeModal` を Redux `openWelcomeModal()` にマップ | +| `src/components/gui/gui.jsx` | DNCL mode notice | DnclModeNotice コンポーネントの import・配置・`onRequestExitDnclMode` prop | +| `src/containers/gui.jsx` | DNCL mode notice | `onRequestExitDnclMode` を Redux `setDnclMode(false)` + `requestExternalExitDnclMode()` にマップ | | `src/components/gui/gui.jsx` | Redux action props prevention | Redux action props の伝播防止 | | `src/components/gui/gui.jsx` | iPad portrait narrow desktop stage size | 744〜1023px viewport で stage を small に強制 (issue #572 Phase 3-C, #599 で 768→744 拡張) | | `src/components/gui/gui.css` | iPad portrait narrow desktop layout | 744〜1023px viewport で editor-wrapper の flex-basis を緩める (issue #572 Phase 3-C, #599 で 768→744 拡張) | diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md index 96c8097eee5..fbd6b9a0276 100644 --- a/.claude/rules/scratch-gui/smalruby-prettier-files.md +++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md @@ -13,6 +13,7 @@ upstream (Scratch) ファイルは対象外。 **Smalruby 固有ディレクトリ(ディレクトリ内の全ファイルが対象):** - `src/components/auto-correct-modal/` +- `src/components/dncl-mode-notice/` - `src/components/block-display-modal/` - `src/components/classroom-modal/` - `src/components/classroom-teacher-modal/` @@ -204,6 +205,8 @@ upstream (Scratch) ファイルは対象外。 - `test/integration/version-update-notification.test.js` - `test/integration/workspace-glow-regression.test.js` - `test/unit/components/action-menu.test.jsx` +- `test/unit/components/dncl-mode-notice.test.jsx` +- `test/unit/components/extension-button-dncl.test.jsx` - `test/unit/components/connected-step.test.jsx` - `test/unit/components/mobile-bottom-tabs.test.jsx` - `test/unit/components/mobile-drawer.test.jsx` @@ -284,6 +287,7 @@ upstream (Scratch) ファイルは対象外。 - `test/unit/make-toolbox-xml-hex.test.js` - `test/unit/only-blocks-initialization.test.js` - `test/unit/reducers/cards_reducer.test.js` +- `test/unit/reducers/dncl-mode-reducer.test.js` - `test/unit/reducers/classroom-reducer.test.js` - `test/unit/reducers/menus-reducer.test.js` - `test/unit/reducers/palette-visibility-reducer.test.js` diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index 82d3fa86f96..7bab2583f56 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -37,6 +37,7 @@ src/components/* !src/components/smalrubot-firmware-modal/ !src/components/blocks-screenshot-button/ !src/components/google-drive-save-dialog/ +!src/components/dncl-mode-notice/ !src/components/koshien-test-modal/ !src/components/mobile-bottom-tabs/ !src/components/mobile-drawer/ @@ -267,6 +268,8 @@ test/unit/* # Level 3: test/unit/components/* test/unit/components/* !test/unit/components/action-menu.test.jsx +!test/unit/components/dncl-mode-notice.test.jsx +!test/unit/components/extension-button-dncl.test.jsx !test/unit/components/connected-step.test.jsx !test/unit/components/mobile-bottom-tabs.test.jsx !test/unit/components/mobile-drawer.test.jsx @@ -362,6 +365,7 @@ test/unit/lib/* # Level 3: test/unit/reducers/* test/unit/reducers/* !test/unit/reducers/cards_reducer.test.js +!test/unit/reducers/dncl-mode-reducer.test.js !test/unit/reducers/classroom-reducer.test.js !test/unit/reducers/menus-reducer.test.js !test/unit/reducers/palette-visibility-reducer.test.js diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css new file mode 100644 index 00000000000..3fe81ad957e --- /dev/null +++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css @@ -0,0 +1,34 @@ +.notice { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background-color: #fff3cd; + border-bottom: 1px solid #ffc107; + font-size: 12px; + flex-shrink: 0; + gap: 8px; +} + +.message { + color: #856404; + font-weight: 500; + flex: 1; +} + +.exitButton { + background: none; + border: 1px solid #856404; + border-radius: 4px; + color: #856404; + cursor: pointer; + font-size: 11px; + padding: 3px 8px; + white-space: nowrap; + flex-shrink: 0; +} + +.exitButton:hover { + background-color: #ffc107; + color: #fff; +} diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx new file mode 100644 index 00000000000..828881dac98 --- /dev/null +++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx @@ -0,0 +1,55 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import intlShape from '../../lib/intlShape.js'; +import styles from './dncl-mode-notice.css'; + +const messages = defineMessages({ + message: { + id: 'gui.dnclModeNotice.message', + description: 'Notice shown in blocks tab when DNCL mode is active', + defaultMessage: 'Japanese mode: blocks are restricted', + }, + exitButton: { + id: 'gui.dnclModeNotice.exitButton', + description: 'Button label to exit DNCL mode from the blocks tab notice', + defaultMessage: 'Return to Ruby furigana mode', + }, + exitConfirm: { + id: 'gui.extensionButton.dnclExtensionConfirm', + description: 'Confirm dialog when switching out of DNCL mode', + defaultMessage: + 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?', + }, +}); + +const DnclModeNotice = ({ dnclMode, onExitDnclMode, intl }) => { + const handleExitClick = useCallback(() => { + // eslint-disable-next-line no-alert + const confirmed = window.confirm(intl.formatMessage(messages.exitConfirm)); + if (confirmed) onExitDnclMode?.(); + }, [onExitDnclMode, intl]); + + if (!dnclMode) return null; + + return ( +
+ {intl.formatMessage(messages.message)} + +
+ ); +}; + +DnclModeNotice.propTypes = { + dnclMode: PropTypes.bool, + intl: intlShape.isRequired, + onExitDnclMode: PropTypes.func, +}; + +export default injectIntl(DnclModeNotice); diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 9f6dc3f281e..d81ab9a5853 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -24,6 +24,9 @@ import Watermark from '../../containers/watermark.jsx'; import Backpack from '../../containers/backpack.jsx'; import ExtensionsButton from '../extension-button/extension-button.jsx'; +// === Smalruby: Start of DNCL mode notice === +import DnclModeNotice from '../dncl-mode-notice/dncl-mode-notice.jsx'; +// === Smalruby: End of DNCL mode notice === import WebGlModal from '../../containers/webgl-modal.jsx'; import TipsLibrary from '../../containers/tips-library.jsx'; import Cards from '../../containers/cards.jsx'; @@ -252,6 +255,7 @@ const GUIComponent = props => { onSetPlatform, onSetTheme, onOpenClassroomModal, + onRequestExitDnclMode, // === Smalruby: End of Redux action props prevention === rubyTabVisible, showComingSoon, @@ -671,11 +675,18 @@ const GUIComponent = props => { colorMode={colorMode} />
+ {/* === Smalruby: Start of DNCL mode notice === */} + + {/* === Smalruby: End of DNCL mode notice === */} {/* === Smalruby: Start of DNCL extension button === */} {/* === Smalruby: End of DNCL extension button === */} @@ -835,6 +846,7 @@ GUIComponent.propTypes = { onClickLogo: PropTypes.func, onCloseAccountNav: PropTypes.func, onExtensionButtonClick: PropTypes.func, + onRequestExitDnclMode: PropTypes.func, // === Smalruby: DNCL mode notice === onLogOut: PropTypes.func, onNewSpriteClick: PropTypes.func, onNewLibraryCostumeClick: PropTypes.func, diff --git a/packages/scratch-gui/src/containers/gui.jsx b/packages/scratch-gui/src/containers/gui.jsx index cf8468a8cb5..bfbaf12ed65 100644 --- a/packages/scratch-gui/src/containers/gui.jsx +++ b/packages/scratch-gui/src/containers/gui.jsx @@ -37,6 +37,10 @@ import {setPlatform} from '../reducers/platform'; import {setTheme} from '../reducers/settings'; import {setDynamicAssets} from '../reducers/dynamic-assets'; import {showAlertWithTimeout} from '../reducers/alerts'; +import { // === Smalruby: DNCL mode notice === + setDnclMode, + requestExternalExitDnclMode, +} from '../reducers/dncl-mode'; // === Smalruby: DNCL mode notice === import {highlightTarget} from '../reducers/targets'; import { rubyCodeShape, @@ -328,6 +332,12 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => ({ onExtensionButtonClick: () => dispatch(openExtensionLibrary()), + // === Smalruby: Start of DNCL mode notice === + onRequestExitDnclMode: () => { + dispatch(setDnclMode(false)); + dispatch(requestExternalExitDnclMode()); + }, + // === Smalruby: End of DNCL mode notice === onActivateTab: tab => dispatch(activateTab(tab)), onUpdateDynamicAssets: dynamicAssets => dispatch(setDynamicAssets(dynamicAssets)), onActivateCostumesTab: () => dispatch(activateTab(COSTUMES_TAB_INDEX)), diff --git a/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx b/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx new file mode 100644 index 00000000000..c03cab9bf7b --- /dev/null +++ b/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx @@ -0,0 +1,61 @@ +/* eslint-env jest */ +import '@testing-library/jest-dom'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import DnclModeNotice from '../../../src/components/dncl-mode-notice/dncl-mode-notice.jsx'; + +const renderNotice = (props) => + render( + + + , + ); + +describe('DnclModeNotice', () => { + let onExitDnclMode; + + beforeEach(() => { + onExitDnclMode = jest.fn(); + jest.spyOn(window, 'confirm').mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('renders when dnclMode is true', () => { + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + expect(getByTestId('dncl-mode-notice')).toBeInTheDocument(); + }); + + test('does not render when dnclMode is false', () => { + const { queryByTestId } = renderNotice({ dnclMode: false, onExitDnclMode }); + expect(queryByTestId('dncl-mode-notice')).not.toBeInTheDocument(); + }); + + test('exit button is present', () => { + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + expect(getByTestId('dncl-mode-notice-exit-button')).toBeInTheDocument(); + }); + + test('shows confirm dialog when exit button is clicked', () => { + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + fireEvent.click(getByTestId('dncl-mode-notice-exit-button')); + expect(window.confirm).toHaveBeenCalledTimes(1); + }); + + test('does not call onExitDnclMode when confirm is cancelled', () => { + window.confirm.mockReturnValue(false); + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + fireEvent.click(getByTestId('dncl-mode-notice-exit-button')); + expect(onExitDnclMode).not.toHaveBeenCalled(); + }); + + test('calls onExitDnclMode when confirm is accepted', () => { + window.confirm.mockReturnValue(true); + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + fireEvent.click(getByTestId('dncl-mode-notice-exit-button')); + expect(onExitDnclMode).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx b/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx index 7380a7d55d8..0b7b56424f4 100644 --- a/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx +++ b/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx @@ -3,8 +3,8 @@ import '@testing-library/jest-dom'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import { IntlProvider } from 'react-intl'; -import { ModalFocusContext } from '../../../src/contexts/modal-focus-context.jsx'; import ExtensionButton from '../../../src/components/extension-button/extension-button.jsx'; +import { ModalFocusContext } from '../../../src/contexts/modal-focus-context.jsx'; const mockFocusContext = { captureFocus: jest.fn(), restoreFocus: jest.fn() }; From 4140e22b3099ca9a92684daefcb7428ebb467fa2 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 23 May 2026 00:18:47 +0900 Subject: [PATCH 04/10] fix(dncl): position DnclModeNotice as absolute overlay inside blocksWrapper Move the notice inside the blocksWrapper Box and use position:absolute so it floats over the top of the blocks panel without displacing the workspace area. Co-Authored-By: Claude Sonnet 4.6 --- .../components/dncl-mode-notice/dncl-mode-notice.css | 9 +++++++-- packages/scratch-gui/src/components/gui/gui.jsx | 12 ++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css index 3fe81ad957e..686885f36d5 100644 --- a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css +++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css @@ -1,13 +1,18 @@ .notice { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; - background-color: #fff3cd; + background-color: rgba(255, 243, 205, 0.95); border-bottom: 1px solid #ffc107; font-size: 12px; - flex-shrink: 0; gap: 8px; + pointer-events: auto; } .message { diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index d81ab9a5853..91d2affd331 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -674,13 +674,13 @@ const GUIComponent = props => { vm={vm} colorMode={colorMode} /> + {/* === Smalruby: Start of DNCL mode notice === */} + + {/* === Smalruby: End of DNCL mode notice === */} - {/* === Smalruby: Start of DNCL mode notice === */} - - {/* === Smalruby: End of DNCL mode notice === */} {/* === Smalruby: Start of DNCL extension button === */} Date: Sat, 23 May 2026 00:20:58 +0900 Subject: [PATCH 05/10] feat(dncl): skip DNCL re-enable in wasDncl path when exit was externally requested When the user clicks the extension button or the notice banner to exit DNCL mode, redux sets exitDnclModeExternallyRequested=true. When the Ruby tab subsequently opens and hits the wasDncl path, it checks this flag and skips calling handleToggleDnclMode(), leaving the editor in furigana/ruby mode. The flag is cleared via onClearExitDnclModeRequest after being consumed. Co-Authored-By: Claude Sonnet 4.6 --- .../scratch-gui/src/containers/ruby-tab.jsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 634a6c9773e..267c5baae52 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -30,7 +30,10 @@ import RubyToBlocksConverterHOC from '../lib/ruby-to-blocks-converter-hoc.jsx'; import { containsV1Code } from '../lib/ruby-to-blocks-converter/v1-detection'; import { getUrlParams } from '../lib/url-params'; import { showAlertWithTimeout, closeAlertWithId } from '../reducers/alerts'; -import { setDnclMode as setDnclModeAction } from '../reducers/dncl-mode'; +import { + setDnclMode as setDnclModeAction, + clearExternalExitDnclModeRequest, +} from '../reducers/dncl-mode'; import { BLOCKS_TAB_INDEX, RUBY_TAB_INDEX } from '../reducers/editor-tab'; import { setAiSaveStatus, clearAiSaveStatus } from '../reducers/koshien-file'; import { closeFileMenu } from '../reducers/menus.js'; @@ -123,6 +126,8 @@ const RubyTab = (props) => { v1PromptDismissed, onDismissV1Prompt, onSetDnclMode, + exitDnclModeExternallyRequested, + onClearExitDnclModeRequest, } = props; // --- State --- @@ -1161,12 +1166,17 @@ const RubyTab = (props) => { updateRubyCodeTargetState(vm.editingTarget, rubyVersion); // Schedule DNCL switch after React re-renders with the new // Ruby code and the Monaco editor value prop is committed. + // Skip if an external exit was requested (e.g. from extension button). if (wasDncl) { - requestAnimationFrame(() => { - setTimeout(() => { - handleToggleDnclMode(); - }, 0); - }); + if (exitDnclModeExternallyRequested) { + onClearExitDnclModeRequest?.(); + } else { + requestAnimationFrame(() => { + setTimeout(() => { + handleToggleDnclMode(); + }, 0); + }); + } } } } @@ -1328,6 +1338,8 @@ RubyTab.propTypes = { v1PromptDismissed: PropTypes.bool, onDismissV1Prompt: PropTypes.func, onSetDnclMode: PropTypes.func, + exitDnclModeExternallyRequested: PropTypes.bool, + onClearExitDnclModeRequest: PropTypes.func, }; const mapStateToProps = (state) => ({ @@ -1340,6 +1352,7 @@ const mapStateToProps = (state) => ({ locale: state.locales.locale, activeTabIndex: state.scratchGui.editorTab.activeTabIndex, v1PromptDismissed: state.scratchGui.settings.v1PromptDismissed, + exitDnclModeExternallyRequested: state.scratchGui.dnclMode.exitDnclModeExternallyRequested, }); const mapDispatchToProps = (dispatch) => ({ @@ -1359,6 +1372,7 @@ const mapDispatchToProps = (dispatch) => ({ onMarkRubyTabUsed: () => dispatch(markRubyTabUsed()), onDismissV1Prompt: () => dispatch(dismissV1Prompt()), onSetDnclMode: (dnclMode) => dispatch(setDnclModeAction(dnclMode)), + onClearExitDnclModeRequest: () => dispatch(clearExternalExitDnclModeRequest()), }); const ConnectedRubyTab = RubyteeModalHOC( From bb4347dd4e125a7fd6f2de09a3545746505d9fba Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 23 May 2026 00:23:00 +0900 Subject: [PATCH 06/10] feat(dncl): add locale strings and right-align DnclModeNotice - Replace dnclExtensionDisabled alert string with dnclExtensionConfirm confirm dialog text in en/ja/ja-Hira locales - Add gui.dnclModeNotice.message and gui.dnclModeNotice.exitButton - Right-align notice (flex-end) so message appears in the workspace area and is not hidden behind the block toolbox panel Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/dncl-mode-notice/dncl-mode-notice.css | 2 +- packages/scratch-gui/src/locales/en.js | 5 ++++- packages/scratch-gui/src/locales/ja-Hira.js | 5 ++++- packages/scratch-gui/src/locales/ja.js | 5 ++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css index 686885f36d5..1cbcc806d74 100644 --- a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css +++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css @@ -6,7 +6,7 @@ z-index: 10; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; padding: 6px 12px; background-color: rgba(255, 243, 205, 0.95); border-bottom: 1px solid #ffc107; diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js index 11a922e7647..05017140f3f 100644 --- a/packages/scratch-gui/src/locales/en.js +++ b/packages/scratch-gui/src/locales/en.js @@ -667,7 +667,10 @@ export default { 'gui.menuBar.tutorialTooltip': 'Try Ruby!', 'gui.welcomeTooltip.label': 'Welcome to Smalruby', 'gui.aria.clearButton': 'Clear', - 'gui.extensionButton.dnclExtensionDisabled': 'Extensions are not available in Japanese mode.', + 'gui.extensionButton.dnclExtensionConfirm': + 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?', + 'gui.dnclModeNotice.message': 'Japanese mode: blocks are restricted.', + 'gui.dnclModeNotice.exitButton': 'Return to Ruby furigana mode', 'gui.rubyTab.dnclValidationError': 'This code contains constructs not supported in Japanese mode.\nPlease use only supported instructions before switching modes.', 'gui.mobile.drawer.title': 'Menu', diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 6413854e139..6a82abe5c15 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -1003,7 +1003,10 @@ export default { 'gui.menuBar.tutorialTooltip': 'ルビーをためしてみよう!', 'gui.welcomeTooltip.label': 'スモウルビーへようこそ', 'gui.aria.clearButton': 'クリア', - 'gui.extensionButton.dnclExtensionDisabled': 'にほんごモードではかくちょうきのうはつかえません。', + 'gui.extensionButton.dnclExtensionConfirm': + 'にほんごモードではかくちょうきのうはつかえません。\nRubyふりがなモードにもどすとかくちょうきのうがつかえるようになります。\nもどしますか?', + 'gui.dnclModeNotice.message': 'にほんごモード:ブロックがせいげんされています', + 'gui.dnclModeNotice.exitButton': 'Rubyふりがなモードにもどす', 'gui.rubyTab.dnclValidationError': 'にほんごモードではたいおうしていないきじゅつです。\nたいおうしているめいれいのみにしてから、モードきりかえをおこなってください。', 'gui.mobile.drawer.title': 'メニュー', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index b4f5f47dd68..fd7875979fa 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -976,7 +976,10 @@ export default { 'gui.menuBar.tutorialTooltip': 'ルビーを試してみよう!', 'gui.welcomeTooltip.label': 'スモウルビーへようこそ', 'gui.aria.clearButton': 'クリア', - 'gui.extensionButton.dnclExtensionDisabled': '日本語モードでは拡張機能は使えません。', + 'gui.extensionButton.dnclExtensionConfirm': + '日本語モードでは拡張機能は使えません。\nRubyふりがなモードに戻すと拡張機能が使えるようになります。\n戻しますか?', + 'gui.dnclModeNotice.message': '日本語モード:ブロックが制限されています', + 'gui.dnclModeNotice.exitButton': 'Rubyふりがなモードに戻す', 'gui.rubyTab.dnclValidationError': '日本語モードでは対応していない記述です。\n対応している命令のみにしてから、モード切り替えを行ってください。', 'gui.mobile.drawer.title': 'メニュー', From bc7234d48a3e0130c32dabd090df99b94250032f Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 23 May 2026 00:24:37 +0900 Subject: [PATCH 07/10] fix(dncl): anchor DnclModeNotice to right edge to avoid toolbox overlap Use right:0 / left:auto so the notice floats only in the workspace area, not behind the block toolbox panel. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/dncl-mode-notice/dncl-mode-notice.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css index 1cbcc806d74..ff9af49ffff 100644 --- a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css +++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css @@ -1,15 +1,14 @@ .notice { position: absolute; top: 0; - left: 0; right: 0; z-index: 10; display: flex; align-items: center; - justify-content: flex-end; padding: 6px 12px; background-color: rgba(255, 243, 205, 0.95); border-bottom: 1px solid #ffc107; + border-left: 1px solid #ffc107; font-size: 12px; gap: 8px; pointer-events: auto; From c6e43762f1a75ad013a7be445092dc9ae60462bb Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 23 May 2026 00:29:56 +0900 Subject: [PATCH 08/10] fix(dncl): add dnclMode to shouldComponentUpdate in blocks.jsx Without this, shouldComponentUpdate returned false when only dnclMode changed, preventing componentDidUpdate from running and the toolbox from refreshing. Now the blocks toolbox immediately shows the full set of categories when DNCL mode is exited from the Code tab. Co-Authored-By: Claude Sonnet 4.6 --- packages/scratch-gui/src/containers/blocks.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/containers/blocks.jsx b/packages/scratch-gui/src/containers/blocks.jsx index 768e60f3cb1..67983df39c4 100644 --- a/packages/scratch-gui/src/containers/blocks.jsx +++ b/packages/scratch-gui/src/containers/blocks.jsx @@ -270,7 +270,8 @@ class Blocks extends React.Component { this.props.stageSize !== nextProps.stageSize || this.props.selectedBlocks !== nextProps.selectedBlocks || this.props.tutorialAllowedBlocks !== nextProps.tutorialAllowedBlocks || - this.props.paletteVisible !== nextProps.paletteVisible + this.props.paletteVisible !== nextProps.paletteVisible || + this.props.dnclMode !== nextProps.dnclMode // === Smalruby: DNCL block filtering === ); } componentDidUpdate (prevProps) { From aa661359ff4682bcf454fc37ec96f65397d7e08f Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 23 May 2026 00:32:22 +0900 Subject: [PATCH 09/10] docs: add DNCL block filtering marker for blocks.jsx to smalruby-markers.md Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/scratch-gui/smalruby-markers.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/rules/scratch-gui/smalruby-markers.md b/.claude/rules/scratch-gui/smalruby-markers.md index 0f88223624c..f9ea1aedb3e 100644 --- a/.claude/rules/scratch-gui/smalruby-markers.md +++ b/.claude/rules/scratch-gui/smalruby-markers.md @@ -101,6 +101,7 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一 | `src/containers/menu.jsx` | iPad menu item click fix | メニュー項目クリック時の `setTimeout` 遅延を 0 → 100ms に拡大。iPadOS Safari は `pointerup` から `click` 発火まで ~16–32ms 程度のラグがあり、setTimeout(0) で close すると `
  • ` が click 発火前に unmount され React onClick が skip される問題への対応 | | `src/containers/blocks.jsx` | palette-toggle initial render | `componentDidMount` 末尾で `_applyPaletteVisibility` を呼び `forceUpdate()` を起動。`this.workspace` はインスタンス変数なので `inject()` 後に再レンダーが走らず、初回 `render()` で workspace=null のまま PaletteToggle がスキップされる問題への対応 (issue #695) | | `src/containers/blocks.jsx` | iOS flyout touch bleed fix | MobileGui (SP) で「ブロックを作る」タップ時に iOS の SVG タッチイベントが「変数を作る」にも伝播する問題の修正。`handlePromptStart` を 50ms 遅延して `externalProcedureDefCallback` が先に呼ばれた場合にキャンセル (issue #698) | +| `src/containers/blocks.jsx` | DNCL block filtering | `shouldComponentUpdate` に `dnclMode` を追加して日本語モード切り替え時に即時再レンダリングを保証。import・`getToolboxXML` 内フィルター・`mapStateToProps` への `dnclMode` 追加も含む | ## 関連ファイル From 6c656ca7d72ec2341c2217f1102ac4a190c94736 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 23 May 2026 00:44:27 +0900 Subject: [PATCH 10/10] fix(ci): format ruby-tab.jsx and fix mock state in test - Run prettier on ruby-tab.jsx to fix lint failure - Add exitDnclModeExternallyRequested to dnclMode mock state in ruby-tab-project-changed.test.js to fix TypeError Co-Authored-By: Claude Sonnet 4.6 --- packages/scratch-gui/src/containers/ruby-tab.jsx | 5 +---- .../test/unit/containers/ruby-tab-project-changed.test.js | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 267c5baae52..d23502c397c 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -30,10 +30,7 @@ import RubyToBlocksConverterHOC from '../lib/ruby-to-blocks-converter-hoc.jsx'; import { containsV1Code } from '../lib/ruby-to-blocks-converter/v1-detection'; import { getUrlParams } from '../lib/url-params'; import { showAlertWithTimeout, closeAlertWithId } from '../reducers/alerts'; -import { - setDnclMode as setDnclModeAction, - clearExternalExitDnclModeRequest, -} from '../reducers/dncl-mode'; +import { setDnclMode as setDnclModeAction, clearExternalExitDnclModeRequest } from '../reducers/dncl-mode'; import { BLOCKS_TAB_INDEX, RUBY_TAB_INDEX } from '../reducers/editor-tab'; import { setAiSaveStatus, clearAiSaveStatus } from '../reducers/koshien-file'; import { closeFileMenu } from '../reducers/menus.js'; diff --git a/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js b/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js index be25a796da1..6cbfaba52e8 100644 --- a/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js +++ b/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js @@ -122,6 +122,7 @@ describe('Ruby tab projectChanged on edit', () => { alerts: { alertsList: [] }, tutorialOnboarding: { rubyTabUsed: true }, koshienFile: { aiSaveStatus: null }, + dnclMode: { dnclMode: false, exitDnclModeExternallyRequested: false }, }, locales: { locale: 'en' }, });