From 32f11405908c367cb7109907cb3371c6354b88bc Mon Sep 17 00:00:00 2001 From: jikim-msft Date: Wed, 4 Mar 2026 13:34:40 -0800 Subject: [PATCH 1/6] tutorial --- docs/docs/start/interactive-tutorial.mdx | 13 + docs/package.json | 2 + docs/pnpm-lock.yaml | 395 +++++++++ .../components/playground/ModuleSelector.tsx | 56 ++ .../playground/PlaygroundWorkspace.tsx | 118 +++ docs/src/components/playground/StepGuide.tsx | 180 ++++ .../components/playground/StepIndicator.tsx | 51 ++ .../playground/TutorialPlayground.tsx | 110 +++ .../components/playground/ValidationBadge.tsx | 37 + .../playground/data/diceRollerTutorial.ts | 560 ++++++++++++ .../playground/data/sharedTreeTutorial.ts | 801 ++++++++++++++++++ docs/src/components/playground/data/types.ts | 105 +++ docs/src/components/playground/index.ts | 6 + docs/src/css/playground.css | 366 ++++++++ 14 files changed, 2800 insertions(+) create mode 100644 docs/docs/start/interactive-tutorial.mdx create mode 100644 docs/src/components/playground/ModuleSelector.tsx create mode 100644 docs/src/components/playground/PlaygroundWorkspace.tsx create mode 100644 docs/src/components/playground/StepGuide.tsx create mode 100644 docs/src/components/playground/StepIndicator.tsx create mode 100644 docs/src/components/playground/TutorialPlayground.tsx create mode 100644 docs/src/components/playground/ValidationBadge.tsx create mode 100644 docs/src/components/playground/data/diceRollerTutorial.ts create mode 100644 docs/src/components/playground/data/sharedTreeTutorial.ts create mode 100644 docs/src/components/playground/data/types.ts create mode 100644 docs/src/components/playground/index.ts create mode 100644 docs/src/css/playground.css diff --git a/docs/docs/start/interactive-tutorial.mdx b/docs/docs/start/interactive-tutorial.mdx new file mode 100644 index 000000000000..f75c2f8f0159 --- /dev/null +++ b/docs/docs/start/interactive-tutorial.mdx @@ -0,0 +1,13 @@ +--- +title: Interactive Tutorial +sidebar_position: 5 +hide_table_of_contents: true +--- + +import { TutorialPlayground } from "@site/src/components/playground"; + +# Interactive Tutorial + +Learn Fluid Framework hands-on by writing real code in your browser — no local setup required. + + diff --git a/docs/package.json b/docs/package.json index 5ae1d273187b..da66f8d16466 100644 --- a/docs/package.json +++ b/docs/package.json @@ -60,6 +60,8 @@ }, "devDependencies": { "@azure/static-web-apps-cli": "^2.0.1", + "@codesandbox/sandpack-react": "^2.19.0", + "@codesandbox/sandpack-themes": "^2.0.21", "@docusaurus/core": "^3.6.2", "@docusaurus/eslint-plugin": "^3.6.2", "@docusaurus/module-type-aliases": "^3.6.2", diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 28ad8b461999..5ac0bb428c25 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: '@azure/static-web-apps-cli': specifier: ^2.0.1 version: 2.0.1 + '@codesandbox/sandpack-react': + specifier: ^2.19.0 + version: 2.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@codesandbox/sandpack-themes': + specifier: ^2.0.21 + version: 2.0.21 '@docusaurus/core': specifier: ^3.6.2 version: 3.6.2(@mdx-js/react@3.1.0(@types/react@19.2.14)(react@18.3.1))(acorn@8.15.0)(eslint@8.57.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) @@ -971,6 +977,48 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@codemirror/autocomplete@6.20.1': + resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} + + '@codemirror/commands@6.10.2': + resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/language@6.12.2': + resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==} + + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} + + '@codemirror/state@6.5.4': + resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} + + '@codemirror/view@6.39.16': + resolution: {integrity: sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==} + + '@codesandbox/nodebox@0.1.8': + resolution: {integrity: sha512-2VRS6JDSk+M+pg56GA6CryyUSGPjBEe8Pnae0QL3jJF1mJZJVMDKr93gJRtBbLkfZN6LD/DwMtf+2L0bpWrjqg==} + + '@codesandbox/sandpack-client@2.19.8': + resolution: {integrity: sha512-CMV4nr1zgKzVpx4I3FYvGRM5YT0VaQhALMW9vy4wZRhEyWAtJITQIqZzrTGWqB1JvV7V72dVEUCUPLfYz5hgJQ==} + + '@codesandbox/sandpack-react@2.20.0': + resolution: {integrity: sha512-takd1YpW/PMQ6KPQfvseWLHWklJovGY8QYj8MtWnskGKbjOGJ6uZfyZbcJ6aCFLQMpNyjTqz9AKNbvhCOZ1TUQ==} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + + '@codesandbox/sandpack-themes@2.0.21': + resolution: {integrity: sha512-CMH/MO/dh6foPYb/3eSn2Cu/J3+1+/81Fsaj7VggICkCrmRk0qG5dmgjGAearPTnRkOGORIPHuRqwNXgw0E6YQ==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -1538,6 +1586,27 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/css@1.3.1': + resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} @@ -1665,6 +1734,9 @@ packages: resolution: {integrity: sha512-mDYOl8RmldLkOg9i9YKgyBlpcyi/bNySoIVHJ2EJd2qCmZaXRKQKRW2Zkx92bwjik8jfs/A3EFI+p4DsrXi57g==} engines: {node: '>=18.0.0'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -1781,6 +1853,16 @@ packages: '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + '@react-hook/intersection-observer@3.1.2': + resolution: {integrity: sha512-mWU3BMkmmzyYMSuhO9wu3eJVP21N8TcgYm9bZnTrMwuM818bEk+0NRM3hP+c/TqA9Ln5C7qE53p1H0QMtzYdvQ==} + peerDependencies: + react: '>=16.8' + + '@react-hook/passive-layout-effect@1.2.1': + resolution: {integrity: sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==} + peerDependencies: + react: '>=16.8' + '@rushstack/eslint-patch@1.12.0': resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} @@ -1845,6 +1927,9 @@ packages: '@slorber/remark-comment@1.0.0': resolution: {integrity: sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==} + '@stitches/core@1.2.8': + resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -2661,6 +2746,9 @@ packages: resolution: {integrity: sha512-Yf3Swz1s63hjvBVZ/9f2P1Uu48GjmjCN+Esxb6MAONMGtZB1fRX8/S1AhUTtsuTlcGovbYLxpHgc7wEzstDZBw==} engines: {node: '>= 14.0.0'} + anser@2.3.5: + resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} + ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -2904,6 +2992,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -3060,6 +3151,9 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} + clean-set@1.1.2: + resolution: {integrity: sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -3309,6 +3403,9 @@ packages: typescript: optional: true + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} @@ -3599,6 +3696,10 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} + d@1.0.2: + resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} + engines: {node: '>=0.12'} + dagre-d3-es@7.0.11: resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==} @@ -3992,10 +4093,21 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es5-ext@0.10.64: + resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} + engines: {node: '>=0.10'} + + es6-iterator@2.0.3: + resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} + es6-promisify@7.0.0: resolution: {integrity: sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==} engines: {node: '>=6'} + es6-symbol@3.1.4: + resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} + engines: {node: '>=0.12'} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -4006,6 +4118,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-carriage@1.3.1: + resolution: {integrity: sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==} + escape-goat@4.0.0: resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} engines: {node: '>=12'} @@ -4176,6 +4291,10 @@ packages: deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true + esniff@2.0.1: + resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} + engines: {node: '>=0.10'} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4245,6 +4364,9 @@ packages: resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} engines: {node: '>= 0.8'} + event-emitter@0.3.5: + resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + event-stream@3.3.4: resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} @@ -4270,6 +4392,9 @@ packages: exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + ext@1.7.0: + resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -4991,6 +5116,10 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} + intersection-observer@0.10.0: + resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==} + deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -5632,6 +5761,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + make-dir@1.3.0: resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} engines: {node: '>=4'} @@ -6091,6 +6224,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next-tick@1.1.0: + resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -6249,6 +6385,9 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} + outvariant@1.4.0: + resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -7046,6 +7185,9 @@ packages: typescript: optional: true + react-devtools-inline@4.4.0: + resolution: {integrity: sha512-ES0GolSrKO8wsKbsEkVeiR/ZAaHQTY4zDh1UW8DImVmm8oaGLl3ijJDvSGe+qDRKPZdPRnDtWWnSvvrgxXdThQ==} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -7071,6 +7213,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-json-view-lite@1.5.0: resolution: {integrity: sha512-nWqA1E4jKPklL2jvHWs6s+7Na0qNgw9HCP6xehdQJeg6nPBTFZgGwyko9Q0oj+jQWKTTVRS30u0toM5wiuL3iw==} engines: {node: '>=14'} @@ -7671,6 +7816,9 @@ packages: engines: {node: '>=16'} hasBin: true + static-browser-server@1.0.3: + resolution: {integrity: sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -7693,6 +7841,9 @@ packages: stream-combiner@0.0.4: resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + strict-event-emitter@0.4.6: + resolution: {integrity: sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -7779,6 +7930,9 @@ packages: stubborn-fs@1.2.5: resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-object@0.4.4: resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} @@ -8032,6 +8186,9 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type@2.7.3: + resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -8326,6 +8483,9 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + wait-on@7.2.0: resolution: {integrity: sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==} engines: {node: '>=12.0.0'} @@ -9741,6 +9901,116 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@codemirror/autocomplete@6.20.1': + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + + '@codemirror/commands@6.10.2': + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.1 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.1 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/lint': 6.9.5 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + '@lezer/javascript': 1.5.4 + + '@codemirror/language@6.12.2': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.5': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + crelt: 1.0.6 + + '@codemirror/state@6.5.4': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.39.16': + dependencies: + '@codemirror/state': 6.5.4 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@codesandbox/nodebox@0.1.8': + dependencies: + outvariant: 1.4.0 + strict-event-emitter: 0.4.6 + + '@codesandbox/sandpack-client@2.19.8': + dependencies: + '@codesandbox/nodebox': 0.1.8 + buffer: 6.0.3 + dequal: 2.0.3 + mime-db: 1.54.0 + outvariant: 1.4.0 + static-browser-server: 1.0.3 + + '@codesandbox/sandpack-react@2.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.2 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@codesandbox/sandpack-client': 2.19.8 + '@lezer/highlight': 1.2.3 + '@react-hook/intersection-observer': 3.1.2(react@18.3.1) + '@stitches/core': 1.2.8 + anser: 2.3.5 + clean-set: 1.1.2 + dequal: 2.0.3 + escape-carriage: 1.3.1 + lz-string: 1.5.0 + react: 18.3.1 + react-devtools-inline: 4.4.0 + react-dom: 18.3.1(react@18.3.1) + react-is: 17.0.2 + + '@codesandbox/sandpack-themes@2.0.21': {} + '@colors/colors@1.5.0': optional: true @@ -11007,6 +11277,36 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} + '@lezer/common@1.5.1': {} + + '@lezer/css@1.3.1': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.1 + + '@marijn/find-cluster-break@1.0.2': {} + '@mdx-js/mdx@3.1.0(acorn@8.15.0)': dependencies: '@types/estree': 1.0.8 @@ -11264,6 +11564,8 @@ snapshots: dependencies: '@oclif/core': 4.0.33 + '@open-draft/deferred-promise@2.2.0': {} + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -11348,6 +11650,16 @@ snapshots: '@polka/url@1.0.0-next.28': {} + '@react-hook/intersection-observer@3.1.2(react@18.3.1)': + dependencies: + '@react-hook/passive-layout-effect': 1.2.1(react@18.3.1) + intersection-observer: 0.10.0 + react: 18.3.1 + + '@react-hook/passive-layout-effect@1.2.1(react@18.3.1)': + dependencies: + react: 18.3.1 + '@rushstack/eslint-patch@1.12.0': {} '@rushstack/eslint-plugin-security@0.11.0(eslint@8.57.1)(typescript@5.5.4)': @@ -11424,6 +11736,8 @@ snapshots: micromark-util-character: 1.2.0 micromark-util-symbol: 1.1.0 + '@stitches/core@1.2.8': {} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -12425,6 +12739,8 @@ snapshots: '@algolia/requester-fetch': 5.15.0 '@algolia/requester-node-http': 5.15.0 + anser@2.3.5: {} + ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -12725,6 +13041,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + builtin-modules@3.3.0: {} bytes@3.0.0: {} @@ -12891,6 +13212,8 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + clean-set@1.1.2: {} + clean-stack@2.2.0: {} clean-stack@3.0.1: @@ -13130,6 +13453,8 @@ snapshots: optionalDependencies: typescript: 5.5.4 + crelt@1.0.6: {} + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -13465,6 +13790,11 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 + d@1.0.2: + dependencies: + es5-ext: 0.10.64 + type: 2.7.3 + dagre-d3-es@7.0.11: dependencies: d3: 7.9.0 @@ -14050,8 +14380,26 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es5-ext@0.10.64: + dependencies: + es6-iterator: 2.0.3 + es6-symbol: 3.1.4 + esniff: 2.0.1 + next-tick: 1.1.0 + + es6-iterator@2.0.3: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + es6-symbol: 3.1.4 + es6-promisify@7.0.0: {} + es6-symbol@3.1.4: + dependencies: + d: 1.0.2 + ext: 1.7.0 + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -14068,6 +14416,8 @@ snapshots: escalade@3.2.0: {} + escape-carriage@1.3.1: {} + escape-goat@4.0.0: {} escape-html@1.0.3: {} @@ -14326,6 +14676,13 @@ snapshots: transitivePeerDependencies: - supports-color + esniff@2.0.1: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-emitter: 0.3.5 + type: 2.7.3 + espree@10.4.0: dependencies: acorn: 8.15.0 @@ -14400,6 +14757,11 @@ snapshots: '@types/node': 22.9.1 require-like: 0.1.2 + event-emitter@0.3.5: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-stream@3.3.4: dependencies: duplexer: 0.1.2 @@ -14466,6 +14828,10 @@ snapshots: exsolve@1.0.7: {} + ext@1.7.0: + dependencies: + type: 2.7.3 + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -15332,6 +15698,8 @@ snapshots: interpret@1.4.0: {} + intersection-observer@0.10.0: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -15909,6 +16277,8 @@ snapshots: lunr@2.3.9: {} + lz-string@1.5.0: {} + make-dir@1.3.0: dependencies: pify: 3.0.0 @@ -16716,6 +17086,8 @@ snapshots: neo-async@2.6.2: {} + next-tick@1.1.0: {} + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -16891,6 +17263,8 @@ snapshots: os-tmpdir@1.0.2: {} + outvariant@1.4.0: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -17755,6 +18129,10 @@ snapshots: - supports-color - vue-template-compiler + react-devtools-inline@4.4.0: + dependencies: + es6-symbol: 3.1.4 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -17784,6 +18162,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-json-view-lite@1.5.0(react@18.3.1): dependencies: react: 18.3.1 @@ -18569,6 +18949,13 @@ snapshots: transitivePeerDependencies: - supports-color + static-browser-server@1.0.3: + dependencies: + '@open-draft/deferred-promise': 2.2.0 + dotenv: 16.4.7 + mime-db: 1.54.0 + outvariant: 1.4.0 + statuses@1.5.0: {} statuses@2.0.1: {} @@ -18586,6 +18973,8 @@ snapshots: dependencies: duplexer: 0.1.2 + strict-event-emitter@0.4.6: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -18704,6 +19093,8 @@ snapshots: stubborn-fs@1.2.5: {} + style-mod@4.1.3: {} + style-to-object@0.4.4: dependencies: inline-style-parser: 0.1.1 @@ -18942,6 +19333,8 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type@2.7.3: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -19355,6 +19748,8 @@ snapshots: vscode-uri@3.0.8: {} + w3c-keyname@2.2.8: {} + wait-on@7.2.0: dependencies: axios: 1.12.2(debug@4.3.7) diff --git a/docs/src/components/playground/ModuleSelector.tsx b/docs/src/components/playground/ModuleSelector.tsx new file mode 100644 index 000000000000..de05f16934d9 --- /dev/null +++ b/docs/src/components/playground/ModuleSelector.tsx @@ -0,0 +1,56 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import React from "react"; + +import type { TutorialModule } from "./data/types"; + +/** + * {@link ModuleSelector} component props. + */ +export interface ModuleSelectorProps { + /** + * Available tutorial modules. + */ + modules: TutorialModule[]; + + /** + * Callback when a module is selected. + */ + onSelect: (moduleId: string) => void; +} + +/** + * Renders a card grid for selecting a tutorial module. + */ +export function ModuleSelector({ + modules, + onSelect, +}: ModuleSelectorProps): React.ReactElement { + return ( +
+ {modules.map((mod) => ( + + ))} +
+ ); +} diff --git a/docs/src/components/playground/PlaygroundWorkspace.tsx b/docs/src/components/playground/PlaygroundWorkspace.tsx new file mode 100644 index 000000000000..a7e92c4461fe --- /dev/null +++ b/docs/src/components/playground/PlaygroundWorkspace.tsx @@ -0,0 +1,118 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { SandpackFiles } from "@codesandbox/sandpack-react"; +import { + SandpackCodeEditor, + SandpackLayout, + SandpackPreview, + SandpackProvider, + useSandpack, +} from "@codesandbox/sandpack-react"; +import React from "react"; + +/** + * Internal component that listens for code changes inside SandpackProvider. + */ +function CodeChangeListener({ + activeFile, + onCodeChange, +}: { + activeFile: string; + onCodeChange: (code: string) => void; +}): null { + const { sandpack } = useSandpack(); + + React.useEffect(() => { + const code = sandpack.files[activeFile]?.code ?? ""; + onCodeChange(code); + }, [sandpack.files, activeFile, onCodeChange]); + + return null; +} + +/** + * {@link PlaygroundWorkspace} component props. + */ +export interface PlaygroundWorkspaceProps { + /** + * Sandpack file map for the current step. + */ + files: SandpackFiles; + + /** + * Which file is active in the editor. + */ + activeFile: string; + + /** + * NPM dependencies for the Sandpack sandbox. + */ + dependencies: Record; + + /** + * Callback when the user's code changes. + */ + onCodeChange: (code: string) => void; +} + +/** + * Vite entry HTML that references /main.tsx as the module entry point. + * Provided to ensure the vite-react-ts template loads our custom entry file. + */ +const indexHtml = ` + + + +
+ + +`; + +/** + * Wraps Sandpack editor and preview in a side-by-side layout. + * + * Uses the vite-react-ts template so that code is transpiled by esbuild + * instead of Babel. Babel's ES5 class transpilation adds _classCallCheck + * which is incompatible with SharedTree's Reflect.construct-based proxy + * node system, causing "Cannot call a class as a function" errors. + */ +export function PlaygroundWorkspace({ + files, + activeFile, + dependencies, + onCodeChange, +}: PlaygroundWorkspaceProps): React.ReactElement { + return ( +
+ + + + + + + +
+ ); +} diff --git a/docs/src/components/playground/StepGuide.tsx b/docs/src/components/playground/StepGuide.tsx new file mode 100644 index 000000000000..b3f8099bc8f6 --- /dev/null +++ b/docs/src/components/playground/StepGuide.tsx @@ -0,0 +1,180 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import React from "react"; + +import { StepIndicator } from "./StepIndicator"; +import { ValidationBadge } from "./ValidationBadge"; +import type { TutorialStep } from "./data/types"; + +/** + * {@link StepGuide} component props. + */ +export interface StepGuideProps { + /** + * The current tutorial step. + */ + step: TutorialStep; + + /** + * Current step index (0-based). + */ + currentStepIndex: number; + + /** + * Total number of steps in the module. + */ + totalSteps: number; + + /** + * Validation results for each pattern (parallel array to step.validationPatterns). + */ + validationResults: boolean[]; + + /** + * Whether the solution is currently shown. + */ + showSolution: boolean; + + /** + * Callback when user navigates to a step. + */ + onNavigate: (stepIndex: number) => void; + + /** + * Callback to toggle showing the solution. + */ + onToggleSolution: () => void; + + /** + * Callback to go back to module selection. + */ + onBackToModules: () => void; +} + +/** + * Renders the step instructions, hints, validation checklist, and navigation. + */ +export function StepGuide({ + step, + currentStepIndex, + totalSteps, + validationResults, + showSolution, + onNavigate, + onToggleSolution, + onBackToModules, +}: StepGuideProps): React.ReactElement { + const [expandedHints, setExpandedHints] = React.useState>(new Set()); + + const toggleHint = (index: number): void => { + setExpandedHints((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + // Reset expanded hints when step changes + React.useEffect(() => { + setExpandedHints(new Set()); + }, [step.id]); + + const allPassed = validationResults.length > 0 && validationResults.every(Boolean); + + return ( +
+ + +

{step.title}

+

{step.description}

+ + {step.hints.length > 0 && ( +
+

Hints

+ {step.hints.map((hint, i) => ( +
+ + {expandedHints.has(i) && ( +
+ {hint} +
+ )} +
+ ))} +
+ )} + + {step.validationPatterns.length > 0 && ( +
+

Checklist

+ {step.validationPatterns.map((pattern, i) => ( + + ))} + {allPassed && ( +
+ All checks passed! +
+ )} +
+ )} + +
+ + +
+ {currentStepIndex > 0 && ( + + )} + + {step.solution !== undefined && ( + + )} + + {currentStepIndex < totalSteps - 1 && ( + + )} +
+
+
+ ); +} diff --git a/docs/src/components/playground/StepIndicator.tsx b/docs/src/components/playground/StepIndicator.tsx new file mode 100644 index 000000000000..7bfb1f523725 --- /dev/null +++ b/docs/src/components/playground/StepIndicator.tsx @@ -0,0 +1,51 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import React from "react"; + +/** + * {@link StepIndicator} component props. + */ +export interface StepIndicatorProps { + /** + * Current step index (0-based). + */ + currentStep: number; + + /** + * Total number of steps. + */ + totalSteps: number; +} + +/** + * Renders a step progress indicator bar. + */ +export function StepIndicator({ + currentStep, + totalSteps, +}: StepIndicatorProps): React.ReactElement { + return ( +
+ + Step {currentStep + 1} of {totalSteps} + +
+ {Array.from({ length: totalSteps }, (_, i) => ( +
+ ))} +
+
+ ); +} diff --git a/docs/src/components/playground/TutorialPlayground.tsx b/docs/src/components/playground/TutorialPlayground.tsx new file mode 100644 index 000000000000..389292f79d29 --- /dev/null +++ b/docs/src/components/playground/TutorialPlayground.tsx @@ -0,0 +1,110 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import React from "react"; + +import { ModuleSelector } from "./ModuleSelector"; +import { PlaygroundWorkspace } from "./PlaygroundWorkspace"; +import { StepGuide } from "./StepGuide"; +import { diceRollerTutorial } from "./data/diceRollerTutorial"; +import { sharedTreeTutorial } from "./data/sharedTreeTutorial"; +import type { TutorialModule } from "./data/types"; + +import "@site/src/css/playground.css"; + +const modules: TutorialModule[] = [diceRollerTutorial, sharedTreeTutorial]; + +/** + * Top-level interactive tutorial playground component. + * + * @remarks + * Manages module selection, step navigation, code validation, and solution display. + */ +export function TutorialPlayground(): React.ReactElement { + const [selectedModuleId, setSelectedModuleId] = React.useState( + undefined, + ); + const [currentStepIndex, setCurrentStepIndex] = React.useState(0); + const [validationResults, setValidationResults] = React.useState([]); + const [showSolution, setShowSolution] = React.useState(false); + + const selectedModule = modules.find((m) => m.id === selectedModuleId); + const currentStep = selectedModule?.steps[currentStepIndex]; + + const validateCode = React.useCallback( + (code: string) => { + if (currentStep === undefined) return; + const results = currentStep.validationPatterns.map((vp) => { + const regex = new RegExp(vp.pattern, vp.flags ?? "s"); + return regex.test(code); + }); + setValidationResults(results); + }, + [currentStep], + ); + + const handleModuleSelect = (moduleId: string): void => { + setSelectedModuleId(moduleId); + setCurrentStepIndex(0); + setValidationResults([]); + setShowSolution(false); + }; + + const handleNavigate = (stepIndex: number): void => { + setCurrentStepIndex(stepIndex); + setValidationResults([]); + setShowSolution(false); + }; + + const handleToggleSolution = (): void => { + setShowSolution((prev) => !prev); + }; + + const handleBackToModules = (): void => { + setSelectedModuleId(undefined); + setCurrentStepIndex(0); + setValidationResults([]); + setShowSolution(false); + }; + + if (selectedModule === undefined || currentStep === undefined) { + return ( +
+

+ Choose a tutorial module to get started. Each module walks you through + building a real Fluid application step by step, right in your browser. +

+ +
+ ); + } + + // Build the file map, replacing the active file with the solution if shown + const files = showSolution && currentStep.solution !== undefined + ? { ...currentStep.files, [currentStep.activeFile]: currentStep.solution } + : currentStep.files; + + return ( +
+ + +
+ ); +} diff --git a/docs/src/components/playground/ValidationBadge.tsx b/docs/src/components/playground/ValidationBadge.tsx new file mode 100644 index 000000000000..c26f22948ea0 --- /dev/null +++ b/docs/src/components/playground/ValidationBadge.tsx @@ -0,0 +1,37 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import React from "react"; + +/** + * {@link ValidationBadge} component props. + */ +export interface ValidationBadgeProps { + /** + * Human-readable label for this check. + */ + label: string; + + /** + * Whether the check passed. + */ + passed: boolean; +} + +/** + * Renders a single validation check with a pass/fail indicator. + */ +export function ValidationBadge({ label, passed }: ValidationBadgeProps): React.ReactElement { + return ( +
+ + {passed ? "\u2713" : "\u2717"} + + {label} +
+ ); +} diff --git a/docs/src/components/playground/data/diceRollerTutorial.ts b/docs/src/components/playground/data/diceRollerTutorial.ts new file mode 100644 index 000000000000..121a8c6a3b88 --- /dev/null +++ b/docs/src/components/playground/data/diceRollerTutorial.ts @@ -0,0 +1,560 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { TutorialModule } from "./types"; + +const mainTsx = `import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +const root = createRoot(document.getElementById("root")!); +root.render(); +`; + +const stylesCss = `body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 0; + padding: 16px; + background: #f5f5f5; +} + +.dice-container { + display: flex; + gap: 24px; + flex-wrap: wrap; + justify-content: center; +} + +.dice-panel { + background: white; + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + text-align: center; + min-width: 200px; +} + +.dice-face { + font-size: 80px; + margin: 16px 0; + line-height: 1; +} + +.roll-button { + background: #0078d4; + color: white; + border: none; + border-radius: 8px; + padding: 12px 32px; + font-size: 16px; + cursor: pointer; + transition: background 0.2s; +} + +.roll-button:hover { + background: #106ebe; +} + +h2 { + margin: 0 0 8px; + color: #333; +} + +h3 { + text-align: center; + color: #666; + margin-bottom: 16px; +} +`; + +export const diceRollerTutorial: TutorialModule = { + id: "dice-roller", + title: "Dice Roller", + description: + "Build a collaborative dice roller that syncs between two views using SharedTree \u2014 the core Fluid DDS.", + difficulty: "Beginner", + dependencies: { + "fluid-framework": "^2.90.0", + react: "^18.3.1", + "react-dom": "^18.3.1", + }, + steps: [ + { + id: "define-schema", + title: "Step 1: Define Your Schema", + description: + "Every Fluid application starts with a schema. You'll use `SchemaFactory` to define a `Dice` object with a `value` field. The schema tells SharedTree the shape of your data and enables type-safe access.", + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; + +// TODO: Import SchemaFactory from "fluid-framework" + +// TODO: Create a SchemaFactory instance with a unique namespace +// e.g., const sf = new SchemaFactory("dice-roller"); + +// TODO: Define a Dice schema using sf.object() +// e.g., const Dice = sf.object("Dice", { value: sf.number }); + +export default function App() { + return ( +
+

Dice Roller

+

Start by defining your schema above!

+
+ ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + 'Import SchemaFactory: import { SchemaFactory } from "fluid-framework";', + 'Create a factory: const sf = new SchemaFactory("dice-roller");', + 'Define the schema: const Dice = sf.object("Dice", { value: sf.number });', + ], + validationPatterns: [ + { + label: "Import SchemaFactory", + pattern: "import\\s*\\{[^}]*SchemaFactory[^}]*\\}\\s*from\\s*[\"']fluid-framework[\"']", + }, + { + label: "Create SchemaFactory instance", + pattern: "new\\s+SchemaFactory\\s*\\(", + }, + { + label: "Define Dice with value field", + pattern: "sf\\.(object|objectRecursive)\\s*\\(\\s*[\"']Dice[\"']", + }, + ], + solution: `import React from "react"; +import { SchemaFactory } from "fluid-framework"; + +const sf = new SchemaFactory("dice-roller"); + +const Dice = sf.object("Dice", { value: sf.number }); + +export default function App() { + return ( +
+

Dice Roller

+

Schema defined! Move to the next step.

+
+ ); +} +`, + }, + { + id: "create-tree", + title: "Step 2: Create an In-Memory SharedTree", + description: + "Now create an in-memory SharedTree using `createIndependentTreeBeta()`. This gives you a fully functional SharedTree without any server. First call `createIndependentTreeBeta()` to get a tree, then call `tree.viewWith()` with your schema config to get a view. Finally, initialize the view with a starting value \u2014 pass a plain object like `{ value: 1 }` and SharedTree automatically matches it to your schema.", + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; +import { SchemaFactory } from "fluid-framework"; + +const sf = new SchemaFactory("dice-roller"); + +const Dice = sf.object("Dice", { value: sf.number }); + +// TODO: Import createIndependentTreeBeta and TreeViewConfiguration +// from "fluid-framework" (add them to the existing import) + +// TODO: Create a tree and then a view: +// const tree = createIndependentTreeBeta(); +// const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); + +// TODO: Initialize the view with a plain object: +// view.initialize({ value: 1 }); + +export default function App() { + return ( +
+

Dice Roller

+

Create your SharedTree above!

+
+ ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + 'Add to your import: import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework";', + "Create tree: const tree = createIndependentTreeBeta();", + "Create view: const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice }));", + "Initialize: view.initialize({ value: 1 });", + ], + validationPatterns: [ + { + label: "Import createIndependentTreeBeta", + pattern: "import\\s*\\{[^}]*createIndependentTreeBeta[^}]*\\}\\s*from\\s*[\"']fluid-framework", + }, + { + label: "Import TreeViewConfiguration", + pattern: "TreeViewConfiguration", + }, + { + label: "Create tree", + pattern: "createIndependentTreeBeta\\s*\\(", + }, + { + label: "Create view with viewWith", + pattern: "\\.viewWith\\s*\\(", + }, + { + label: "Initialize the tree", + pattern: "view\\.initialize\\s*\\(", + }, + ], + solution: `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("dice-roller"); + +const Dice = sf.object("Dice", { value: sf.number }); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); +view.initialize({ value: 1 }); + +export default function App() { + return ( +
+

Dice Roller

+

Tree created and initialized! Move to the next step.

+
+ ); +} +`, + }, + { + id: "build-view", + title: "Step 3: Build the Dice View", + description: + "Now render the dice value from your SharedTree. Read `view.root.value` and display it as a dice face emoji. The dice faces are: 1=\u2680, 2=\u2681, 3=\u2682, 4=\u2683, 5=\u2684, 6=\u2685.", + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("dice-roller"); + +const Dice = sf.object("Dice", { value: sf.number }); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); +view.initialize({ value: 1 }); + +const diceFaces = ["\u2680", "\u2681", "\u2682", "\u2683", "\u2684", "\u2685"]; + +// TODO: Create a DiceView component that: +// 1. Reads the current value from view.root.value +// 2. Displays the dice face emoji using the diceFaces array +// 3. Shows the numeric value + +export default function App() { + return ( +
+

Dice Roller

+ {/* TODO: Render your DiceView component here */} +
+ ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + "Create a function component: function DiceView() { ... }", + "Read the value: const value = view.root.value;", + "Show the face:
{diceFaces[value - 1]}
", + "Render it: inside the App return", + ], + validationPatterns: [ + { + label: "Read view.root.value", + pattern: "view\\.root\\.value", + }, + { + label: "Render dice face", + pattern: "diceFaces", + }, + { + label: "DiceView component used in JSX", + pattern: " +
{diceFaces[value - 1]}
+

Value: {value}

+
+ ); +} + +export default function App() { + return ( +
+

Dice Roller

+ +
+ ); +} +`, + }, + { + id: "add-roll", + title: "Step 4: Add the Roll Button", + description: + "Add a button that rolls the dice. When clicked, it should set `view.root.value` to a random number between 1 and 6. This directly mutates the SharedTree node \u2014 exactly how you'd do it in a real Fluid app.", + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("dice-roller"); + +const Dice = sf.object("Dice", { value: sf.number }); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); +view.initialize({ value: 1 }); + +const diceFaces = ["\u2680", "\u2681", "\u2682", "\u2683", "\u2684", "\u2685"]; + +function DiceView() { + const value = view.root.value; + return ( +
+
{diceFaces[value - 1]}
+

Value: {value}

+ {/* TODO: Add a roll button that sets view.root.value to a random 1-6 */} +
+ ); +} + +export default function App() { + return ( +
+

Dice Roller

+ +
+ ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + "Add a + + ); +} + +export default function App() { + return ( +
+

Dice Roller

+ +
+ ); +} +`, + }, + { + id: "two-client-sync", + title: "Step 5: Simulate Two-Client Sync", + description: + 'Now for the magic of Fluid! Add `Tree.on(view.root, "nodeChanged", callback)` to listen for changes and use React state to trigger re-renders. Then render **two** DiceView panels side by side \u2014 both share the same SharedTree, so when either clicks "Roll", both update. This simulates the multi-client experience.', + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, Tree, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("dice-roller"); + +const Dice = sf.object("Dice", { value: sf.number }); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); +view.initialize({ value: 1 }); + +const diceFaces = ["\u2680", "\u2681", "\u2682", "\u2683", "\u2684", "\u2685"]; + +function DiceView({ title }: { title: string }) { + const [value, setValue] = React.useState(view.root.value); + + // TODO: Use React.useEffect to subscribe to tree changes: + // Tree.on(view.root, "nodeChanged", () => setValue(view.root.value)) + // Don't forget to return the cleanup function! + + const roll = () => { + view.root.value = Math.floor(Math.random() * 6) + 1; + }; + + return ( +
+

{title}

+
{diceFaces[value - 1]}
+

Value: {value}

+ +
+ ); +} + +export default function App() { + return ( +
+

Both panels share the same SharedTree!

+
+ {/* TODO: Render TWO DiceView components with different titles */} + {/* e.g., "Client A" and "Client B" */} +
+
+ ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + 'Use React.useEffect(() => { ... }, []) to set up the subscription once', + 'Subscribe: const unsubscribe = Tree.on(view.root, "nodeChanged", () => setValue(view.root.value));', + "Return cleanup: return unsubscribe;", + 'Render two panels: and ', + ], + validationPatterns: [ + { + label: 'Tree.on subscription with "nodeChanged"', + pattern: 'Tree\\.on\\s*\\(\\s*view\\.root\\s*,\\s*["\']nodeChanged["\']', + }, + { + label: "useEffect for subscription", + pattern: "useEffect", + }, + { + label: "Two DiceView instances rendered", + pattern: ".* { + const unsubscribe = Tree.on(view.root, "nodeChanged", () => { + setValue(view.root.value); + }); + return unsubscribe; + }, []); + + const roll = () => { + view.root.value = Math.floor(Math.random() * 6) + 1; + }; + + return ( +
+

{title}

+
{diceFaces[value - 1]}
+

Value: {value}

+ +
+ ); +} + +export default function App() { + return ( +
+

Both panels share the same SharedTree!

+
+ + +
+
+ ); +} +`, + }, + ], +}; diff --git a/docs/src/components/playground/data/sharedTreeTutorial.ts b/docs/src/components/playground/data/sharedTreeTutorial.ts new file mode 100644 index 000000000000..fbcb123192c1 --- /dev/null +++ b/docs/src/components/playground/data/sharedTreeTutorial.ts @@ -0,0 +1,801 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { TutorialModule } from "./types"; + +const mainTsx = `import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +const root = createRoot(document.getElementById("root")!); +root.render(); +`; + +const stylesCss = `body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 0; + padding: 16px; + background: #f5f5f5; +} + +.todo-app { + max-width: 500px; + margin: 0 auto; + background: white; + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.todo-header { + margin: 0 0 16px; + color: #333; +} + +.todo-list { + list-style: none; + padding: 0; + margin: 0; +} + +.todo-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid #eee; +} + +.todo-item:last-child { + border-bottom: none; +} + +.todo-item input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.todo-item.completed span { + text-decoration: line-through; + color: #999; +} + +.add-form { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.add-form input[type="text"] { + flex: 1; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; +} + +.add-button { + background: #0078d4; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.add-button:hover { + background: #106ebe; +} + +.stats { + margin-top: 12px; + color: #666; + font-size: 13px; +} +`; + +export const sharedTreeTutorial: TutorialModule = { + id: "shared-tree-todo", + title: "SharedTree Todo App", + description: + "Build a todo list with SharedTree \u2014 learn schema design, array operations, and reactive updates.", + difficulty: "Intermediate", + dependencies: { + "fluid-framework": "^2.90.0", + react: "^18.3.1", + "react-dom": "^18.3.1", + }, + steps: [ + { + id: "define-todo-schema", + title: "Step 1: Define the Todo Schema", + description: + "Define a schema for a todo application. You need a `TodoItem` with `title` (string) and `completed` (boolean) fields, and a `TodoList` with `title` (string) and `items` (array of TodoItem).", + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; + +// TODO: Import SchemaFactory from "fluid-framework" + +// TODO: Create a SchemaFactory with namespace "todo-app" + +// TODO: Define TodoItem with sf.object(): +// const TodoItem = sf.object("TodoItem", { title: sf.string, completed: sf.boolean }); + +// TODO: Define TodoList with sf.object(): +// const TodoList = sf.object("TodoList", { title: sf.string, items: sf.array(TodoItem) }); + +export default function App() { + return ( +
+

Todo App

+

Start by defining your schema above!

+
+ ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + 'Import: import { SchemaFactory } from "fluid-framework";', + 'Create factory: const sf = new SchemaFactory("todo-app");', + 'Define TodoItem: const TodoItem = sf.object("TodoItem", { title: sf.string, completed: sf.boolean });', + 'Define TodoList: const TodoList = sf.object("TodoList", { title: sf.string, items: sf.array(TodoItem) });', + ], + validationPatterns: [ + { + label: "Import SchemaFactory", + pattern: "import\\s*\\{[^}]*SchemaFactory[^}]*\\}\\s*from\\s*[\"']fluid-framework[\"']", + }, + { + label: "Define TodoItem with title and completed", + pattern: "sf\\.(object|objectRecursive)\\s*\\(\\s*[\"']TodoItem[\"']", + }, + { + label: "Define TodoList with items array", + pattern: "sf\\.(object|objectRecursive)\\s*\\(\\s*[\"']TodoList[\"']", + }, + { + label: "Use sf.array for items", + pattern: "sf\\.array\\s*\\(\\s*TodoItem\\s*\\)", + }, + ], + solution: `import React from "react"; +import { SchemaFactory } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +export default function App() { + return ( +
+

Todo App

+

Schema defined! Move to the next step.

+
+ ); +} +`, + }, + { + id: "create-initialize", + title: "Step 2: Create and Initialize the Tree", + description: + "Create an in-memory SharedTree with `createIndependentTreeBeta()` and initialize it with a TodoList containing some sample todo items. First call `createIndependentTreeBeta()` to get a tree, then `tree.viewWith()` with your schema config to get a view. Pass plain objects to `view.initialize()` \u2014 SharedTree automatically matches them to your schema.", + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; +import { SchemaFactory, TreeViewConfiguration } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +// TODO: Add createIndependentTreeBeta to your import from "fluid-framework" + +// TODO: Create a tree and then a view: +// const tree = createIndependentTreeBeta(); +// const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); + +// TODO: Initialize the view with sample data using plain objects: +// view.initialize({ +// title: "My Todos", +// items: [ +// { title: "Learn SharedTree schema", completed: true }, +// { title: "Build a todo app", completed: false }, +// { title: "Add reactive updates", completed: false }, +// ], +// }); + +export default function App() { + return ( +
+

Todo App

+

Create and initialize your tree above!

+
+ ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + 'Add to your import: import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework";', + "Create tree: const tree = createIndependentTreeBeta();", + "Create view: const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList }));", + "Initialize with plain objects: view.initialize({ title: \"My Todos\", items: [...] })", + ], + validationPatterns: [ + { + label: "Import createIndependentTreeBeta", + pattern: "import\\s*\\{[^}]*createIndependentTreeBeta[^}]*\\}\\s*from\\s*[\"']fluid-framework", + }, + { + label: "Create tree", + pattern: "createIndependentTreeBeta\\s*\\(", + }, + { + label: "Create view with viewWith", + pattern: "\\.viewWith\\s*\\(", + }, + { + label: "Initialize with sample data", + pattern: "view\\.initialize\\s*\\(", + }, + ], + solution: `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); +view.initialize({ + title: "My Todos", + items: [ + { title: "Learn SharedTree schema", completed: true }, + { title: "Build a todo app", completed: false }, + { title: "Add reactive updates", completed: false }, + ], +}); + +export default function App() { + return ( +
+

Todo App

+

Tree created! Move to the next step.

+
+ ); +} +`, + }, + { + id: "read-display", + title: "Step 3: Read and Display Todos", + description: + "Read data from the SharedTree and display it. Iterate over `view.root.items` to render each todo item with its title and completion status.", + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); +view.initialize({ + title: "My Todos", + items: [ + { title: "Learn SharedTree schema", completed: true }, + { title: "Build a todo app", completed: false }, + { title: "Add reactive updates", completed: false }, + ], +}); + +export default function App() { + const todoList = view.root; + + return ( +
+

{todoList.title}

+ {/* TODO: Create a
    and map over todoList.items */} + {/* For each item, render a
  • with: + - className: "todo-item" (add "completed" class if item.completed) + - A checkbox showing item.completed (read-only for now) + - A with item.title + */} +
+ ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + "Use:
    {todoList.items.map((item, i) => ...)}
", + "Each list item:
  • ", + "Add checkbox: ", + "Add title: {item.title}", + ], + validationPatterns: [ + { + label: "Map over todoList.items", + pattern: "(todoList\\.items|view\\.root\\.items)\\.(map|forEach)", + }, + { + label: "Render item title", + pattern: "item\\.title", + }, + { + label: "Render checkbox with completed status", + pattern: "item\\.completed", + }, + ], + solution: `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); +view.initialize({ + title: "My Todos", + items: [ + { title: "Learn SharedTree schema", completed: true }, + { title: "Build a todo app", completed: false }, + { title: "Add reactive updates", completed: false }, + ], +}); + +export default function App() { + const todoList = view.root; + + return ( +
    +

    {todoList.title}

    +
      + {todoList.items.map((item, i) => ( +
    • + + {item.title} +
    • + ))} +
    +
    + ); +} +`, + }, + { + id: "add-editing", + title: "Step 4: Add Editing \u2014 Toggle and Add Items", + description: + "Make the todos interactive! Toggle `item.completed` when a checkbox is clicked, and add a form to insert new items using `todoList.items.insertAtEnd()`. Pass a plain object to `insertAtEnd()` \u2014 SharedTree matches it to the TodoItem schema automatically.", + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); +view.initialize({ + title: "My Todos", + items: [ + { title: "Learn SharedTree schema", completed: true }, + { title: "Build a todo app", completed: false }, + { title: "Add reactive updates", completed: false }, + ], +}); + +export default function App() { + const todoList = view.root; + const [newTitle, setNewTitle] = React.useState(""); + + // TODO: Add a handleToggle function that flips item.completed + // e.g., item.completed = !item.completed; + + // TODO: Add a handleAdd function that: + // 1. Checks newTitle is not empty + // 2. Calls todoList.items.insertAtEnd({ title: newTitle, completed: false }) + // 3. Clears the input + + return ( +
    +

    {todoList.title}

    +
      + {todoList.items.map((item, i) => ( +
    • + {/* TODO: Make checkbox onChange call handleToggle */} + + {item.title} +
    • + ))} +
    + {/* TODO: Add a form with text input and "Add" button */} +
    + ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + "Toggle: const handleToggle = (item) => { item.completed = !item.completed; };", + "On checkbox: onChange={() => handleToggle(item)} \u2014 and remove readOnly", + "Insert: todoList.items.insertAtEnd({ title: newTitle, completed: false });", + "Form:
    ", + ], + validationPatterns: [ + { + label: "Toggle completed", + pattern: "item\\.completed\\s*=\\s*!\\s*item\\.completed", + }, + { + label: "insertAtEnd to add new items", + pattern: "insertAtEnd\\s*\\(", + }, + { + label: "Text input for new todo", + pattern: ']*type\\s*=\\s*["\']text["\']', + }, + ], + solution: `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); +view.initialize({ + title: "My Todos", + items: [ + { title: "Learn SharedTree schema", completed: true }, + { title: "Build a todo app", completed: false }, + { title: "Add reactive updates", completed: false }, + ], +}); + +export default function App() { + const todoList = view.root; + const [newTitle, setNewTitle] = React.useState(""); + + const handleToggle = (item: any) => { + item.completed = !item.completed; + }; + + const handleAdd = () => { + if (newTitle.trim() === "") return; + todoList.items.insertAtEnd( + { title: newTitle.trim(), completed: false } + ); + setNewTitle(""); + }; + + return ( +
    +

    {todoList.title}

    +
      + {todoList.items.map((item, i) => ( +
    • + handleToggle(item)} + /> + {item.title} +
    • + ))} +
    +
    + setNewTitle(e.target.value)} + placeholder="Add a new todo..." + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + /> + +
    +
    + {todoList.items.filter((item) => item.completed).length} of{" "} + {todoList.items.length} completed +
    +
    + ); +} +`, + }, + { + id: "reactive-updates", + title: "Step 5: Reactive Updates with Tree.on", + description: + 'The UI currently doesn\'t re-render when the tree changes. Add `Tree.on(node, "treeChanged", callback)` to subscribe to changes and trigger React re-renders. This is how real Fluid apps stay in sync across clients.', + activeFile: "/App.tsx", + files: { + "/App.tsx": `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, Tree, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); +view.initialize({ + title: "My Todos", + items: [ + { title: "Learn SharedTree schema", completed: true }, + { title: "Build a todo app", completed: false }, + { title: "Add reactive updates", completed: false }, + ], +}); + +export default function App() { + const [, setTick] = React.useState(0); + + // TODO: Use React.useEffect to subscribe to tree changes: + // Tree.on(view.root, "treeChanged", () => setTick((t) => t + 1)) + // Return the unsubscribe function for cleanup. + + const todoList = view.root; + const [newTitle, setNewTitle] = React.useState(""); + + const handleToggle = (item: any) => { + item.completed = !item.completed; + }; + + const handleAdd = () => { + if (newTitle.trim() === "") return; + todoList.items.insertAtEnd( + { title: newTitle.trim(), completed: false } + ); + setNewTitle(""); + }; + + return ( +
    +

    {todoList.title}

    +
      + {todoList.items.map((item, i) => ( +
    • + handleToggle(item)} + /> + {item.title} +
    • + ))} +
    +
    + setNewTitle(e.target.value)} + placeholder="Add a new todo..." + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + /> + +
    +
    + {todoList.items.filter((item) => item.completed).length} of{" "} + {todoList.items.length} completed +
    +
    + ); +} +`, + "/main.tsx": mainTsx, + "/styles.css": stylesCss, + }, + hints: [ + "Add useEffect at the top of your App component", + 'Subscribe: const unsubscribe = Tree.on(view.root, "treeChanged", () => setTick((t) => t + 1));', + "Cleanup: return unsubscribe;", + "The setTick pattern forces a re-render whenever the tree changes", + ], + validationPatterns: [ + { + label: 'Tree.on subscription with "treeChanged"', + pattern: 'Tree\\.on\\s*\\(\\s*view\\.root\\s*,\\s*["\']treeChanged["\']', + }, + { + label: "useEffect for subscription", + pattern: "useEffect", + }, + { + label: "Re-render trigger (setTick or similar state update)", + pattern: "setTick", + }, + ], + solution: `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, Tree, createIndependentTreeBeta } from "fluid-framework"; + +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +}); + +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); +view.initialize({ + title: "My Todos", + items: [ + { title: "Learn SharedTree schema", completed: true }, + { title: "Build a todo app", completed: false }, + { title: "Add reactive updates", completed: false }, + ], +}); + +export default function App() { + const [, setTick] = React.useState(0); + + React.useEffect(() => { + const unsubscribe = Tree.on(view.root, "treeChanged", () => { + setTick((t) => t + 1); + }); + return unsubscribe; + }, []); + + const todoList = view.root; + const [newTitle, setNewTitle] = React.useState(""); + + const handleToggle = (item: any) => { + item.completed = !item.completed; + }; + + const handleAdd = () => { + if (newTitle.trim() === "") return; + todoList.items.insertAtEnd( + { title: newTitle.trim(), completed: false } + ); + setNewTitle(""); + }; + + return ( +
    +

    {todoList.title}

    +
      + {todoList.items.map((item, i) => ( +
    • + handleToggle(item)} + /> + {item.title} +
    • + ))} +
    +
    + setNewTitle(e.target.value)} + placeholder="Add a new todo..." + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + /> + +
    +
    + {todoList.items.filter((item) => item.completed).length} of{" "} + {todoList.items.length} completed +
    +
    + ); +} +`, + }, + ], +}; diff --git a/docs/src/components/playground/data/types.ts b/docs/src/components/playground/data/types.ts new file mode 100644 index 000000000000..ba6e3cdaccd6 --- /dev/null +++ b/docs/src/components/playground/data/types.ts @@ -0,0 +1,105 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * A regex-based validation pattern for checking user code. + */ +export interface ValidationPattern { + /** + * Human-readable label shown in the checklist. + */ + label: string; + + /** + * Regex pattern string to test against the user's code. + */ + pattern: string; + + /** + * Regex flags (e.g. "s" for dotAll). Defaults to "s". + */ + flags?: string; +} + +/** + * A single step within a tutorial module. + */ +export interface TutorialStep { + /** + * Unique step identifier. + */ + id: string; + + /** + * Step title displayed in the guide panel. + */ + title: string; + + /** + * Step instructions/description. + */ + description: string; + + /** + * Sandpack file map for this step. + * Keys are file paths (e.g. "/App.tsx"), values are file content strings. + */ + files: Record; + + /** + * Which file is active/visible in the editor. + */ + activeFile: string; + + /** + * Expandable hint strings to help the user. + */ + hints: string[]; + + /** + * Validation patterns checked against the active file's source. + */ + validationPatterns: ValidationPattern[]; + + /** + * Optional solution code for the active file. + */ + solution?: string; +} + +/** + * A tutorial module grouping multiple steps. + */ +export interface TutorialModule { + /** + * Unique module identifier. + */ + id: string; + + /** + * Module display title. + */ + title: string; + + /** + * Short description shown on the module card. + */ + description: string; + + /** + * Difficulty level. + */ + difficulty: "Beginner" | "Intermediate" | "Advanced"; + + /** + * Ordered list of tutorial steps. + */ + steps: TutorialStep[]; + + /** + * NPM dependencies required by Sandpack for this module. + */ + dependencies: Record; +} diff --git a/docs/src/components/playground/index.ts b/docs/src/components/playground/index.ts new file mode 100644 index 000000000000..671726bb3a9c --- /dev/null +++ b/docs/src/components/playground/index.ts @@ -0,0 +1,6 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export { TutorialPlayground } from "./TutorialPlayground"; diff --git a/docs/src/css/playground.css b/docs/src/css/playground.css new file mode 100644 index 000000000000..110c51d30dcf --- /dev/null +++ b/docs/src/css/playground.css @@ -0,0 +1,366 @@ +.ffcom-playground-container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.ffcom-playground-intro { + color: var(--ifm-color-content-secondary); + font-size: 1.05rem; + margin-bottom: 8px; +} + +/* Module Selector */ +.ffcom-playground-module-selector { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.ffcom-playground-module-card { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + background: var(--ifm-card-background-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + padding: 20px; + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s; + font-family: inherit; + font-size: inherit; + color: inherit; +} + +.ffcom-playground-module-card:hover { + border-color: var(--ifm-color-primary); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); +} + +.ffcom-playground-module-card-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 8px; +} + +.ffcom-playground-module-title { + margin: 0; + font-size: 1.15rem; +} + +.ffcom-playground-difficulty-badge { + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + text-transform: uppercase; +} + +.ffcom-playground-difficulty-beginner { + background: #e6f4ea; + color: #1e7e34; +} + +.ffcom-playground-difficulty-intermediate { + background: #fff3e0; + color: #e65100; +} + +.ffcom-playground-difficulty-advanced { + background: #fce4ec; + color: #c62828; +} + +.ffcom-playground-module-description { + color: var(--ifm-color-content-secondary); + font-size: 0.9rem; + margin: 0 0 12px; + line-height: 1.5; +} + +.ffcom-playground-module-meta { + font-size: 0.8rem; + color: var(--ifm-color-content-secondary); + margin-top: auto; +} + +/* Workspace */ +.ffcom-playground-workspace { + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--ifm-color-emphasis-300); +} + +/* Step Guide */ +.ffcom-playground-guide { + background: var(--ifm-card-background-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + padding: 20px; +} + +.ffcom-playground-step-title { + margin: 12px 0 8px; + font-size: 1.2rem; +} + +.ffcom-playground-step-description { + color: var(--ifm-color-content-secondary); + line-height: 1.6; + margin: 0 0 16px; +} + +.ffcom-playground-step-description code { + background: var(--ifm-color-emphasis-200); + padding: 1px 5px; + border-radius: 3px; + font-size: 0.9em; +} + +/* Step Indicator */ +.ffcom-playground-step-indicator { + display: flex; + align-items: center; + gap: 12px; +} + +.ffcom-playground-step-text { + font-size: 0.85rem; + font-weight: 600; + color: var(--ifm-color-content-secondary); + white-space: nowrap; +} + +.ffcom-playground-step-bar { + display: flex; + gap: 6px; + flex: 1; +} + +.ffcom-playground-step-dot { + height: 6px; + flex: 1; + border-radius: 3px; + background: var(--ifm-color-emphasis-200); + transition: background 0.2s; +} + +.ffcom-playground-step-done { + background: var(--ifm-color-primary); +} + +.ffcom-playground-step-active { + background: var(--ifm-color-primary-light); +} + +/* Hints */ +.ffcom-playground-hints { + margin-bottom: 16px; +} + +.ffcom-playground-hints-title, +.ffcom-playground-validation-title { + font-size: 0.9rem; + font-weight: 600; + margin: 0 0 8px; + color: var(--ifm-color-content-secondary); +} + +.ffcom-playground-hint { + margin-bottom: 4px; +} + +.ffcom-playground-hint-toggle { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + cursor: pointer; + font-size: 0.85rem; + padding: 4px 0; + color: var(--ifm-color-primary); + font-family: inherit; +} + +.ffcom-playground-hint-toggle:hover { + text-decoration: underline; +} + +.ffcom-playground-hint-arrow { + font-size: 0.7rem; + width: 12px; +} + +.ffcom-playground-hint-content { + padding: 8px 12px 8px 18px; + margin: 4px 0; + background: var(--ifm-color-emphasis-100); + border-radius: 4px; +} + +.ffcom-playground-hint-content code { + font-size: 0.82rem; + white-space: pre-wrap; + word-break: break-word; +} + +/* Validation */ +.ffcom-playground-validation { + margin-bottom: 16px; +} + +.ffcom-playground-validation-item { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; + font-size: 0.85rem; +} + +.ffcom-playground-validation-icon { + font-weight: bold; + width: 16px; + text-align: center; +} + +.ffcom-playground-validation-pass { + color: #1e7e34; +} + +.ffcom-playground-validation-fail { + color: #c62828; +} + +.ffcom-playground-validation-label { + color: var(--ifm-color-content); +} + +.ffcom-playground-step-complete { + margin-top: 8px; + padding: 8px 12px; + background: #e6f4ea; + color: #1e7e34; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; +} + +/* Navigation */ +.ffcom-playground-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--ifm-color-emphasis-200); + flex-wrap: wrap; + gap: 8px; +} + +.ffcom-playground-nav-group { + display: flex; + gap: 8px; +} + +.ffcom-playground-nav-button { + padding: 8px 16px; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--ifm-color-emphasis-300); + transition: + background 0.2s, + border-color 0.2s; + font-family: inherit; +} + +.ffcom-playground-nav-primary { + background: var(--ifm-color-primary); + color: white; + border-color: var(--ifm-color-primary); +} + +.ffcom-playground-nav-primary:hover { + background: var(--ifm-color-primary-dark); +} + +.ffcom-playground-nav-secondary { + background: transparent; + color: var(--ifm-color-content); +} + +.ffcom-playground-nav-secondary:hover { + background: var(--ifm-color-emphasis-100); +} + +.ffcom-playground-nav-active { + background: var(--ifm-color-primary-lightest); + color: var(--ifm-color-primary-dark); + border-color: var(--ifm-color-primary); +} + +/* Dark mode */ +[data-theme="dark"] .ffcom-playground-difficulty-beginner { + background: #1b3a26; + color: #6fcf97; +} + +[data-theme="dark"] .ffcom-playground-difficulty-intermediate { + background: #3e2a0e; + color: #ffb74d; +} + +[data-theme="dark"] .ffcom-playground-difficulty-advanced { + background: #3e1418; + color: #ef9a9a; +} + +[data-theme="dark"] .ffcom-playground-step-complete { + background: #1b3a26; + color: #6fcf97; +} + +[data-theme="dark"] .ffcom-playground-validation-pass { + color: #6fcf97; +} + +[data-theme="dark"] .ffcom-playground-validation-fail { + color: #ef9a9a; +} + +/* Break out of Docusaurus content wrapper for full width */ +.ffcom-playground-container { + margin-left: calc(-1 * var(--ifm-spacing-horizontal)); + margin-right: calc(-1 * var(--ifm-spacing-horizontal)); + width: calc(100% + 2 * var(--ifm-spacing-horizontal)); +} + +/* Wide screen: side-by-side layout */ +@media (min-width: 997px) { + .ffcom-playground-container { + flex-direction: row; + align-items: flex-start; + } + + .ffcom-playground-workspace { + flex: 3; + min-width: 0; + } + + .ffcom-playground-guide { + flex: 1; + min-width: 300px; + max-width: 400px; + position: sticky; + top: calc(var(--ifm-navbar-height) + 16px); + max-height: calc(100vh - var(--ifm-navbar-height) - 32px); + overflow-y: auto; + } +} From 6e1eee07966d2749703d9d1f88e00fcaf229074f Mon Sep 17 00:00:00 2001 From: jikim-msft Date: Wed, 4 Mar 2026 16:57:33 -0800 Subject: [PATCH 2/6] no skip --- .../playground/PlaygroundWorkspace.tsx | 35 ++++++++++++------- docs/src/components/playground/StepGuide.tsx | 6 ++-- .../playground/TutorialPlayground.tsx | 6 +++- docs/src/css/playground.css | 8 +++++ 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/src/components/playground/PlaygroundWorkspace.tsx b/docs/src/components/playground/PlaygroundWorkspace.tsx index a7e92c4461fe..1b510fbf76fc 100644 --- a/docs/src/components/playground/PlaygroundWorkspace.tsx +++ b/docs/src/components/playground/PlaygroundWorkspace.tsx @@ -85,23 +85,32 @@ export function PlaygroundWorkspace({ dependencies, onCodeChange, }: PlaygroundWorkspaceProps): React.ReactElement { + // Memoize all object props so SandpackProvider doesn't see new references + // on every re-render (which would reset the editor and undo user typing). + const sandpackFiles = React.useMemo( + () => ({ "/index.html": indexHtml, ...files }), + [files], + ); + + const customSetup = React.useMemo(() => ({ dependencies }), [dependencies]); + + const options = React.useMemo( + () => ({ + activeFile, + visibleFiles: [activeFile], + recompileMode: "delayed" as const, + recompileDelay: 500, + }), + [activeFile], + ); + return (
    onNavigate(currentStepIndex + 1)} + className={`ffcom-playground-nav-button ${allPassed ? "ffcom-playground-nav-primary" : "ffcom-playground-nav-disabled"}`} + onClick={() => allPassed && onNavigate(currentStepIndex + 1)} + disabled={!allPassed} + title={allPassed ? undefined : "Complete all checklist items to continue"} > Next diff --git a/docs/src/components/playground/TutorialPlayground.tsx b/docs/src/components/playground/TutorialPlayground.tsx index 389292f79d29..b041ba7e178a 100644 --- a/docs/src/components/playground/TutorialPlayground.tsx +++ b/docs/src/components/playground/TutorialPlayground.tsx @@ -36,9 +36,13 @@ export function TutorialPlayground(): React.ReactElement { const validateCode = React.useCallback( (code: string) => { if (currentStep === undefined) return; + // Strip comments so validation only matches actual code, not TODO hints. + const strippedCode = code + .replace(/\/\*[\s\S]*?\*\//g, "") // block comments (/* ... */ and {/* ... */}) + .replace(/\/\/.*$/gm, ""); // single-line comments (// ...) const results = currentStep.validationPatterns.map((vp) => { const regex = new RegExp(vp.pattern, vp.flags ?? "s"); - return regex.test(code); + return regex.test(strippedCode); }); setValidationResults(results); }, diff --git a/docs/src/css/playground.css b/docs/src/css/playground.css index 110c51d30dcf..c9d9be7b8821 100644 --- a/docs/src/css/playground.css +++ b/docs/src/css/playground.css @@ -306,6 +306,14 @@ border-color: var(--ifm-color-primary); } +.ffcom-playground-nav-disabled { + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-500); + border-color: var(--ifm-color-emphasis-300); + cursor: not-allowed; + opacity: 0.7; +} + /* Dark mode */ [data-theme="dark"] .ffcom-playground-difficulty-beginner { background: #1b3a26; From 46e26a2db205734867d0f7849773f2cf6d9e9023 Mon Sep 17 00:00:00 2001 From: jikim-msft Date: Fri, 13 Mar 2026 16:45:59 -0700 Subject: [PATCH 3/6] add more changes --- .../interactive-tutorial/dice-roller.mdx | 11 ++ .../index.mdx} | 5 +- .../interactive-tutorial/shared-tree-todo.mdx | 11 ++ .../components/playground/ModuleSelector.tsx | 34 ++-- .../playground/PlaygroundWorkspace.tsx | 6 +- docs/src/components/playground/StepGuide.tsx | 60 +++++-- .../components/playground/StepIndicator.tsx | 14 +- .../playground/TutorialPlayground.tsx | 167 +++++++++++++----- .../playground/data/diceRollerTutorial.ts | 40 ++--- .../playground/data/sharedTreeTutorial.ts | 146 +++++++-------- docs/src/components/playground/index.ts | 1 + docs/src/css/playground.css | 19 +- 12 files changed, 322 insertions(+), 192 deletions(-) create mode 100644 docs/docs/start/interactive-tutorial/dice-roller.mdx rename docs/docs/start/{interactive-tutorial.mdx => interactive-tutorial/index.mdx} (50%) create mode 100644 docs/docs/start/interactive-tutorial/shared-tree-todo.mdx diff --git a/docs/docs/start/interactive-tutorial/dice-roller.mdx b/docs/docs/start/interactive-tutorial/dice-roller.mdx new file mode 100644 index 000000000000..ae56e73fa73c --- /dev/null +++ b/docs/docs/start/interactive-tutorial/dice-roller.mdx @@ -0,0 +1,11 @@ +--- +title: "Dice Roller" +sidebar_position: 1 +hide_table_of_contents: true +--- + +import { TutorialPlayground } from "@site/src/components/playground"; + +# Dice Roller + + diff --git a/docs/docs/start/interactive-tutorial.mdx b/docs/docs/start/interactive-tutorial/index.mdx similarity index 50% rename from docs/docs/start/interactive-tutorial.mdx rename to docs/docs/start/interactive-tutorial/index.mdx index f75c2f8f0159..ffa781f2d04d 100644 --- a/docs/docs/start/interactive-tutorial.mdx +++ b/docs/docs/start/interactive-tutorial/index.mdx @@ -4,10 +4,11 @@ sidebar_position: 5 hide_table_of_contents: true --- -import { TutorialPlayground } from "@site/src/components/playground"; +import { ModuleSelector } from "@site/src/components/playground"; # Interactive Tutorial Learn Fluid Framework hands-on by writing real code in your browser — no local setup required. +Choose a tutorial module to get started. Each module walks you through building a real Fluid application step by step. - + diff --git a/docs/docs/start/interactive-tutorial/shared-tree-todo.mdx b/docs/docs/start/interactive-tutorial/shared-tree-todo.mdx new file mode 100644 index 000000000000..15f696448563 --- /dev/null +++ b/docs/docs/start/interactive-tutorial/shared-tree-todo.mdx @@ -0,0 +1,11 @@ +--- +title: "SharedTree Todo App" +sidebar_position: 2 +hide_table_of_contents: true +--- + +import { TutorialPlayground } from "@site/src/components/playground"; + +# SharedTree Todo App + + diff --git a/docs/src/components/playground/ModuleSelector.tsx b/docs/src/components/playground/ModuleSelector.tsx index de05f16934d9..f1a465d961a0 100644 --- a/docs/src/components/playground/ModuleSelector.tsx +++ b/docs/src/components/playground/ModuleSelector.tsx @@ -3,39 +3,37 @@ * Licensed under the MIT License. */ +import Link from "@docusaurus/Link"; import React from "react"; +import { diceRollerTutorial } from "./data/diceRollerTutorial"; +import { sharedTreeTutorial } from "./data/sharedTreeTutorial"; import type { TutorialModule } from "./data/types"; +import "@site/src/css/playground.css"; + /** - * {@link ModuleSelector} component props. + * Map from module id to its docs sub-page path (relative to the tutorial index). */ -export interface ModuleSelectorProps { - /** - * Available tutorial modules. - */ - modules: TutorialModule[]; +const modulePages: Record = { + "dice-roller": "dice-roller", + "shared-tree-todo": "shared-tree-todo", +}; - /** - * Callback when a module is selected. - */ - onSelect: (moduleId: string) => void; -} +const modules: TutorialModule[] = [diceRollerTutorial, sharedTreeTutorial]; /** * Renders a card grid for selecting a tutorial module. + * Each card links to the module's dedicated sub-page. */ -export function ModuleSelector({ - modules, - onSelect, -}: ModuleSelectorProps): React.ReactElement { +export function ModuleSelector(): React.ReactElement { return (
    {modules.map((mod) => ( - + ))}
    ); diff --git a/docs/src/components/playground/PlaygroundWorkspace.tsx b/docs/src/components/playground/PlaygroundWorkspace.tsx index 1b510fbf76fc..b0d1d0768b9e 100644 --- a/docs/src/components/playground/PlaygroundWorkspace.tsx +++ b/docs/src/components/playground/PlaygroundWorkspace.tsx @@ -112,13 +112,13 @@ export function PlaygroundWorkspace({ customSetup={customSetup} options={options} > - + - +
    diff --git a/docs/src/components/playground/StepGuide.tsx b/docs/src/components/playground/StepGuide.tsx index 9bccda1527cc..0a6c5b2801c3 100644 --- a/docs/src/components/playground/StepGuide.tsx +++ b/docs/src/components/playground/StepGuide.tsx @@ -38,6 +38,11 @@ export interface StepGuideProps { */ showSolution: boolean; + /** + * Set of step indices that have been completed. + */ + completedSteps: Set; + /** * Callback when user navigates to a step. */ @@ -49,9 +54,27 @@ export interface StepGuideProps { onToggleSolution: () => void; /** - * Callback to go back to module selection. + * Callback to reset the current step to boilerplate. */ - onBackToModules: () => void; + onResetStep: () => void; +} + +/** + * Renders a string containing inline markdown (backtick code and **bold**) + * as React elements. + */ +function renderInlineMarkdown(text: string): React.ReactNode { + // Split on `code` and **bold** tokens, preserving delimiters + const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g); + return parts.map((part, i) => { + if (part.startsWith("`") && part.endsWith("`")) { + return {part.slice(1, -1)}; + } + if (part.startsWith("**") && part.endsWith("**")) { + return {part.slice(2, -2)}; + } + return part; + }); } /** @@ -63,14 +86,15 @@ export function StepGuide({ totalSteps, validationResults, showSolution, + completedSteps, onNavigate, onToggleSolution, - onBackToModules, + onResetStep, }: StepGuideProps): React.ReactElement { const [expandedHints, setExpandedHints] = React.useState>(new Set()); const toggleHint = (index: number): void => { - setExpandedHints((prev) => { + setExpandedHints((prev: Set) => { const next = new Set(prev); if (next.has(index)) { next.delete(index); @@ -86,14 +110,20 @@ export function StepGuide({ setExpandedHints(new Set()); }, [step.id]); - const allPassed = validationResults.length > 0 && validationResults.every(Boolean); + const allPassed = + step.validationPatterns.length === 0 || + (validationResults.length > 0 && validationResults.every(Boolean)); return (
    - +

    {step.title}

    -

    {step.description}

    +

    {renderInlineMarkdown(step.description)}

    {step.hints.length > 0 && (
    @@ -112,7 +142,7 @@ export function StepGuide({ {expandedHints.has(i) && (
    - {hint} + {renderInlineMarkdown(hint)}
    )}
    @@ -139,13 +169,6 @@ export function StepGuide({ )}
    - -
    {currentStepIndex > 0 && ( + {currentStepIndex < totalSteps - 1 && (
    ", + "Toggle: `const handleToggle = (item) => { item.completed = !item.completed; };`", + "On checkbox: `onChange={() => handleToggle(item)}` \u2014 and remove `readOnly`", + "Insert: `todoList.items.insertAtEnd({ title: newTitle, completed: false });`", + "Form: `
    `", ], validationPatterns: [ { @@ -593,9 +609,9 @@ export default function App() { }, { id: "reactive-updates", - title: "Step 5: Reactive Updates with Tree.on", + title: "Step 5: Two-Client Sync", description: - 'The UI currently doesn\'t re-render when the tree changes. Add `Tree.on(node, "treeChanged", callback)` to subscribe to changes and trigger React re-renders. This is how real Fluid apps stay in sync across clients.', + 'Now for the magic of Fluid! Extract a `TodoPanel` component from your App, add `Tree.on(view.root, "treeChanged", callback)` to subscribe to changes, and render **two** panels side by side. Both share the same SharedTree, so when either client toggles or adds a todo, both update instantly. This simulates the multi-client experience.', activeFile: "/App.tsx", files: { "/App.tsx": `import React from "react"; @@ -624,62 +640,18 @@ view.initialize({ ], }); -export default function App() { - const [, setTick] = React.useState(0); - - // TODO: Use React.useEffect to subscribe to tree changes: - // Tree.on(view.root, "treeChanged", () => setTick((t) => t + 1)) - // Return the unsubscribe function for cleanup. - - const todoList = view.root; - const [newTitle, setNewTitle] = React.useState(""); - - const handleToggle = (item: any) => { - item.completed = !item.completed; - }; - - const handleAdd = () => { - if (newTitle.trim() === "") return; - todoList.items.insertAtEnd( - { title: newTitle.trim(), completed: false } - ); - setNewTitle(""); - }; +// TODO: Extract a TodoPanel component that accepts a { title: string } prop. +// Move the todo rendering logic here and add Tree.on for reactivity: +// 1. Use React.useState + React.useEffect with Tree.on(view.root, "treeChanged", ...) +// to trigger re-renders when the tree changes +// 2. Include the todo list, toggle, add form, and stats +export default function App() { return ( -
    -

    {todoList.title}

    -
      - {todoList.items.map((item, i) => ( -
    • - handleToggle(item)} - /> - {item.title} -
    • - ))} -
    -
    - setNewTitle(e.target.value)} - placeholder="Add a new todo..." - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - /> - -
    -
    - {todoList.items.filter((item) => item.completed).length} of{" "} - {todoList.items.length} completed -
    +
    +

    Both panels share the same SharedTree!

    + {/* TODO: Render TWO TodoPanel components with different titles */} + {/* e.g., "Client A" and "Client B" */}
    ); } @@ -688,10 +660,10 @@ export default function App() { "/styles.css": stylesCss, }, hints: [ - "Add useEffect at the top of your App component", - 'Subscribe: const unsubscribe = Tree.on(view.root, "treeChanged", () => setTick((t) => t + 1));', - "Cleanup: return unsubscribe;", - "The setTick pattern forces a re-render whenever the tree changes", + "Create `function TodoPanel({ title }: { title: string })` and move the todo UI into it", + 'Subscribe: `const unsubscribe = Tree.on(view.root, "treeChanged", () => setTick((t) => t + 1));`', + "Cleanup: `return unsubscribe;` inside the useEffect", + 'Render two panels: `` and ``', ], validationPatterns: [ { @@ -703,8 +675,12 @@ export default function App() { pattern: "useEffect", }, { - label: "Re-render trigger (setTick or similar state update)", - pattern: "setTick", + label: "TodoPanel component defined", + pattern: "function\\s+TodoPanel", + }, + { + label: "Two TodoPanel instances rendered", + pattern: ".* { @@ -760,7 +736,7 @@ export default function App() { return (
    -

    {todoList.title}

    +

    {title}

      {todoList.items.map((item, i) => (
    • ); } + +export default function App() { + return ( +
      +

      Both panels share the same SharedTree!

      + + +
      + ); +} `, }, ], diff --git a/docs/src/components/playground/index.ts b/docs/src/components/playground/index.ts index 671726bb3a9c..af6883f05863 100644 --- a/docs/src/components/playground/index.ts +++ b/docs/src/components/playground/index.ts @@ -3,4 +3,5 @@ * Licensed under the MIT License. */ +export { ModuleSelector } from "./ModuleSelector"; export { TutorialPlayground } from "./TutorialPlayground"; diff --git a/docs/src/css/playground.css b/docs/src/css/playground.css index c9d9be7b8821..45d60fb38620 100644 --- a/docs/src/css/playground.css +++ b/docs/src/css/playground.css @@ -33,11 +33,14 @@ font-family: inherit; font-size: inherit; color: inherit; + text-decoration: none; } .ffcom-playground-module-card:hover { border-color: var(--ifm-color-primary); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + color: inherit; + text-decoration: none; } .ffcom-playground-module-card-header { @@ -301,11 +304,15 @@ } .ffcom-playground-nav-active { - background: var(--ifm-color-primary-lightest); - color: var(--ifm-color-primary-dark); + background: var(--ifm-color-primary); + color: white; border-color: var(--ifm-color-primary); } +.ffcom-playground-nav-active:hover { + background: var(--ifm-color-primary-dark); +} + .ffcom-playground-nav-disabled { background: var(--ifm-color-emphasis-200); color: var(--ifm-color-emphasis-500); @@ -358,14 +365,14 @@ } .ffcom-playground-workspace { - flex: 3; + flex: 5; min-width: 0; } .ffcom-playground-guide { - flex: 1; - min-width: 300px; - max-width: 400px; + flex: 2; + min-width: 260px; + max-width: 340px; position: sticky; top: calc(var(--ifm-navbar-height) + 16px); max-height: calc(100vh - var(--ifm-navbar-height) - 32px); From f52116d83f9ecf1e54a809beca6bec79a500e17a Mon Sep 17 00:00:00 2001 From: jikim-msft Date: Fri, 13 Mar 2026 18:15:08 -0700 Subject: [PATCH 4/6] more bug and ui fix --- .../components/playground/ModuleSelector.tsx | 18 +- docs/src/components/playground/StepGuide.tsx | 47 ++- .../playground/TutorialPlayground.tsx | 10 +- .../playground/data/diceRollerTutorial.ts | 236 +++++------- .../src/components/playground/data/modules.ts | 21 ++ .../components/playground/data/sharedFiles.ts | 17 + .../playground/data/sharedTreeTutorial.ts | 354 ++++++------------ docs/src/css/custom.scss | 2 +- docs/src/css/playground.css | 45 ++- 9 files changed, 322 insertions(+), 428 deletions(-) create mode 100644 docs/src/components/playground/data/modules.ts create mode 100644 docs/src/components/playground/data/sharedFiles.ts diff --git a/docs/src/components/playground/ModuleSelector.tsx b/docs/src/components/playground/ModuleSelector.tsx index f1a465d961a0..12cbb7422eae 100644 --- a/docs/src/components/playground/ModuleSelector.tsx +++ b/docs/src/components/playground/ModuleSelector.tsx @@ -6,22 +6,10 @@ import Link from "@docusaurus/Link"; import React from "react"; -import { diceRollerTutorial } from "./data/diceRollerTutorial"; -import { sharedTreeTutorial } from "./data/sharedTreeTutorial"; -import type { TutorialModule } from "./data/types"; +import { moduleList } from "./data/modules"; import "@site/src/css/playground.css"; -/** - * Map from module id to its docs sub-page path (relative to the tutorial index). - */ -const modulePages: Record = { - "dice-roller": "dice-roller", - "shared-tree-todo": "shared-tree-todo", -}; - -const modules: TutorialModule[] = [diceRollerTutorial, sharedTreeTutorial]; - /** * Renders a card grid for selecting a tutorial module. * Each card links to the module's dedicated sub-page. @@ -29,11 +17,11 @@ const modules: TutorialModule[] = [diceRollerTutorial, sharedTreeTutorial]; export function ModuleSelector(): React.ReactElement { return (
      - {modules.map((mod) => ( + {moduleList.map((mod) => (

      {mod.title}

      diff --git a/docs/src/components/playground/StepGuide.tsx b/docs/src/components/playground/StepGuide.tsx index 0a6c5b2801c3..78f09ea4f33e 100644 --- a/docs/src/components/playground/StepGuide.tsx +++ b/docs/src/components/playground/StepGuide.tsx @@ -3,6 +3,7 @@ * Licensed under the MIT License. */ +import Link from "@docusaurus/Link"; import React from "react"; import { StepIndicator } from "./StepIndicator"; @@ -169,16 +170,16 @@ export function StepGuide({ )}
      -
      - {currentStepIndex > 0 && ( - - )} + +
      {step.solution !== undefined && ( + Back to Tutorials + )}
      + + {currentStepIndex < totalSteps - 1 ? ( + + ) : ( +
      + )}
      ); diff --git a/docs/src/components/playground/TutorialPlayground.tsx b/docs/src/components/playground/TutorialPlayground.tsx index 03d3cd0ba5a7..c499ef72d62c 100644 --- a/docs/src/components/playground/TutorialPlayground.tsx +++ b/docs/src/components/playground/TutorialPlayground.tsx @@ -7,17 +7,11 @@ import React from "react"; import { PlaygroundWorkspace } from "./PlaygroundWorkspace"; import { StepGuide } from "./StepGuide"; -import { diceRollerTutorial } from "./data/diceRollerTutorial"; -import { sharedTreeTutorial } from "./data/sharedTreeTutorial"; -import type { TutorialModule, ValidationPattern } from "./data/types"; +import { modulesById } from "./data/modules"; +import type { ValidationPattern } from "./data/types"; import "@site/src/css/playground.css"; -const modulesById: Record = { - "dice-roller": diceRollerTutorial, - "shared-tree-todo": sharedTreeTutorial, -}; - /** * Runs validation patterns against code, stripping comments first. */ diff --git a/docs/src/components/playground/data/diceRollerTutorial.ts b/docs/src/components/playground/data/diceRollerTutorial.ts index 82bb18e380c5..0affe84dea62 100644 --- a/docs/src/components/playground/data/diceRollerTutorial.ts +++ b/docs/src/components/playground/data/diceRollerTutorial.ts @@ -3,17 +3,9 @@ * Licensed under the MIT License. */ +import { mainTsx } from "./sharedFiles"; import type { TutorialModule } from "./types"; -const mainTsx = `import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; -import "./styles.css"; - -const root = createRoot(document.getElementById("root")!); -root.render(); -`; - const stylesCss = `body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; @@ -70,6 +62,58 @@ h3 { } `; +// --- Reusable code fragments for composing step files --- + +const importsBase = `import React from "react";`; + +const importsWithSchema = `import React from "react"; +import { SchemaFactory } from "fluid-framework";`; + +const importsWithTree = `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework";`; + +const importsWithTreeAndEvents = `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, Tree, createIndependentTreeBeta } from "fluid-framework";`; + +const schemaBlock = ` +const sf = new SchemaFactory("dice-roller"); + +const Dice = sf.object("Dice", { value: sf.number });`; + +const treeSetupBlock = ` +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); +view.initialize({ value: 1 });`; + +const diceFacesBlock = ` +const diceFaces = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"];`; + +const diceViewWithRoll = ` +function DiceView() { + const value = view.root.value; + + const roll = () => { + view.root.value = Math.floor(Math.random() * 6) + 1; + }; + + return ( +
      +
      {diceFaces[value - 1]}
      +

      Value: {value}

      + +
      + ); +}`; + +// --- Shared scaffold files for every step --- + +const scaffoldFiles = { + "/main.tsx": mainTsx, + "/styles.css": stylesCss, +}; + export const diceRollerTutorial: TutorialModule = { id: "dice-roller", title: "Dice Roller", @@ -89,7 +133,7 @@ export const diceRollerTutorial: TutorialModule = { "Every Fluid application starts with a schema. You'll use `SchemaFactory` to define a `Dice` object with a `value` field. The schema tells SharedTree the shape of your data and enables type-safe access.", activeFile: "/App.tsx", files: { - "/App.tsx": `import React from "react"; + "/App.tsx": `${importsBase} // TODO: Import SchemaFactory from "fluid-framework" @@ -108,8 +152,7 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ 'Import SchemaFactory: `import { SchemaFactory } from "fluid-framework";`', @@ -119,7 +162,8 @@ export default function App() { validationPatterns: [ { label: "Import SchemaFactory", - pattern: "import\\s*\\{[^}]*SchemaFactory[^}]*\\}\\s*from\\s*[\"']fluid-framework[\"']", + pattern: + "import\\s*\\{[^}]*SchemaFactory[^}]*\\}\\s*from\\s*[\"']fluid-framework[\"']", }, { label: "Create SchemaFactory instance", @@ -130,12 +174,8 @@ export default function App() { pattern: "sf\\.(object|objectRecursive)\\s*\\(\\s*[\"']Dice[\"']", }, ], - solution: `import React from "react"; -import { SchemaFactory } from "fluid-framework"; - -const sf = new SchemaFactory("dice-roller"); - -const Dice = sf.object("Dice", { value: sf.number }); + solution: `${importsWithSchema} +${schemaBlock} export default function App() { return ( @@ -154,12 +194,8 @@ export default function App() { "Now create an in-memory SharedTree using `createIndependentTreeBeta()`. This gives you a fully functional SharedTree without any server. First call `createIndependentTreeBeta()` to get a tree, then call `tree.viewWith()` with your schema config to get a view. Finally, initialize the view with a starting value \u2014 pass a plain object like `{ value: 1 }` and SharedTree automatically matches it to your schema.", activeFile: "/App.tsx", files: { - "/App.tsx": `import React from "react"; -import { SchemaFactory } from "fluid-framework"; - -const sf = new SchemaFactory("dice-roller"); - -const Dice = sf.object("Dice", { value: sf.number }); + "/App.tsx": `${importsWithSchema} +${schemaBlock} // TODO: Import createIndependentTreeBeta and TreeViewConfiguration // from "fluid-framework" (add them to the existing import) @@ -180,8 +216,7 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ 'Add to your import: `import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework";`', @@ -192,7 +227,8 @@ export default function App() { validationPatterns: [ { label: "Import createIndependentTreeBeta", - pattern: "import\\s*\\{[^}]*createIndependentTreeBeta[^}]*\\}\\s*from\\s*[\"']fluid-framework", + pattern: + "import\\s*\\{[^}]*createIndependentTreeBeta[^}]*\\}\\s*from\\s*[\"']fluid-framework", }, { label: "Import TreeViewConfiguration", @@ -211,16 +247,9 @@ export default function App() { pattern: "view\\.initialize\\s*\\(", }, ], - solution: `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("dice-roller"); - -const Dice = sf.object("Dice", { value: sf.number }); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); -view.initialize({ value: 1 }); + solution: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} export default function App() { return ( @@ -239,18 +268,10 @@ export default function App() { "Now render the dice value from your SharedTree. Read `view.root.value` and display it as a dice face emoji. The dice faces are: 1=\u2680, 2=\u2681, 3=\u2682, 4=\u2683, 5=\u2684, 6=\u2685.", activeFile: "/App.tsx", files: { - "/App.tsx": `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("dice-roller"); - -const Dice = sf.object("Dice", { value: sf.number }); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); -view.initialize({ value: 1 }); - -const diceFaces = ["\u2680", "\u2681", "\u2682", "\u2683", "\u2684", "\u2685"]; + "/App.tsx": `${importsWithTree} +${schemaBlock} +${treeSetupBlock} +${diceFacesBlock} // TODO: Create a DiceView component that: // 1. Reads the current value from view.root.value @@ -266,13 +287,12 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ "Create a function component: `function DiceView() { ... }`", "Read the value: `const value = view.root.value;`", - "Show the face: `
      {diceFaces[value - 1]}
      `", + 'Show the face: `
      {diceFaces[value - 1]}
      `', "Render it: `` inside the App return", ], validationPatterns: [ @@ -289,18 +309,10 @@ export default function App() { pattern: "` element with an `onClick` handler", "Generate random 1-6: `Math.floor(Math.random() * 6) + 1`", "Set the value: `view.root.value = Math.floor(Math.random() * 6) + 1;`", - "Use `className=\"roll-button\"` for styling", + 'Use `className="roll-button"` for styling', ], validationPatterns: [ { @@ -385,36 +388,11 @@ export default function App() { pattern: "view\\.root\\.value\\s*=", }, ], - solution: `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("dice-roller"); - -const Dice = sf.object("Dice", { value: sf.number }); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); -view.initialize({ value: 1 }); - -const diceFaces = ["\u2680", "\u2681", "\u2682", "\u2683", "\u2684", "\u2685"]; - -function DiceView() { - const value = view.root.value; - - const roll = () => { - view.root.value = Math.floor(Math.random() * 6) + 1; - }; - - return ( -
      -
      {diceFaces[value - 1]}
      -

      Value: {value}

      - -
      - ); -} + solution: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} +${diceFacesBlock} +${diceViewWithRoll} export default function App() { return ( @@ -433,18 +411,10 @@ export default function App() { 'Now for the magic of Fluid! Add `Tree.on(view.root, "nodeChanged", callback)` to listen for changes and use React state to trigger re-renders. Then render **two** DiceView panels side by side \u2014 both share the same SharedTree, so when either clicks "Roll", both update. This simulates the multi-client experience.', activeFile: "/App.tsx", files: { - "/App.tsx": `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, Tree, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("dice-roller"); - -const Dice = sf.object("Dice", { value: sf.number }); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: Dice })); -view.initialize({ value: 1 }); - -const diceFaces = ["\u2680", "\u2681", "\u2682", "\u2683", "\u2684", "\u2685"]; + "/App.tsx": `${importsWithTreeAndEvents} +${schemaBlock} +${treeSetupBlock} +${diceFacesBlock} function DiceView({ title }: { title: string }) { const [value, setValue] = React.useState(view.root.value); @@ -481,8 +451,7 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ 'Use `React.useEffect(() => { ... }, [])` to set up the subscription once', @@ -493,7 +462,8 @@ export default function App() { validationPatterns: [ { label: 'Tree.on subscription with "nodeChanged"', - pattern: 'Tree\\.on\\s*\\(\\s*view\\.root\\s*,\\s*["\']nodeChanged["\']', + pattern: + 'Tree\\.on\\s*\\(\\s*view\\.root\\s*,\\s*["\']nodeChanged["\']', }, { label: "useEffect for subscription", @@ -504,18 +474,10 @@ export default function App() { pattern: ".* = { + "dice-roller": diceRollerTutorial, + "shared-tree-todo": sharedTreeTutorial, +}; + +/** + * Ordered list of all tutorial modules for display in the module selector. + */ +export const moduleList: TutorialModule[] = Object.values(modulesById); diff --git a/docs/src/components/playground/data/sharedFiles.ts b/docs/src/components/playground/data/sharedFiles.ts new file mode 100644 index 000000000000..7a61ed02122c --- /dev/null +++ b/docs/src/components/playground/data/sharedFiles.ts @@ -0,0 +1,17 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Shared Sandpack entry file used by all tutorial modules. + * Renders the App component into the root div. + */ +export const mainTsx = `import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +const root = createRoot(document.getElementById("root")!); +root.render(); +`; diff --git a/docs/src/components/playground/data/sharedTreeTutorial.ts b/docs/src/components/playground/data/sharedTreeTutorial.ts index b2f7637eb3eb..8a941b7ae6be 100644 --- a/docs/src/components/playground/data/sharedTreeTutorial.ts +++ b/docs/src/components/playground/data/sharedTreeTutorial.ts @@ -3,17 +3,9 @@ * Licensed under the MIT License. */ +import { mainTsx } from "./sharedFiles"; import type { TutorialModule } from "./types"; -const mainTsx = `import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; -import "./styles.css"; - -const root = createRoot(document.getElementById("root")!); -root.render(); -`; - const stylesCss = `body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; @@ -116,6 +108,69 @@ const stylesCss = `body { } `; +// --- Reusable code fragments for composing step files --- + +const importsBase = `import React from "react";`; + +const importsWithSchema = `import React from "react"; +import { SchemaFactory } from "fluid-framework";`; + +const importsWithTree = `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework";`; + +const importsWithTreeAndEvents = `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, Tree, createIndependentTreeBeta } from "fluid-framework";`; + +const schemaBlock = ` +const sf = new SchemaFactory("todo-app"); + +const TodoItem = sf.object("TodoItem", { + title: sf.string, + completed: sf.boolean, +}); + +const TodoList = sf.object("TodoList", { + title: sf.string, + items: sf.array(TodoItem), +});`; + +const treeSetupBlock = ` +const tree = createIndependentTreeBeta(); +const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); +view.initialize({ + title: "My Todos", + items: [ + { title: "Learn SharedTree schema", completed: true }, + { title: "Build a todo app", completed: false }, + { title: "Add reactive updates", completed: false }, + ], +});`; + +const todoListRendering = ` + return ( +
      +

      {todoList.title}

      +
        + {todoList.items.map((item, i) => ( +
      • + + {item.title} +
      • + ))} +
      +
      + );`; + +// --- Shared scaffold files for every step --- + +const scaffoldFiles = { + "/main.tsx": mainTsx, + "/styles.css": stylesCss, +}; + export const sharedTreeTutorial: TutorialModule = { id: "shared-tree-todo", title: "SharedTree Todo App", @@ -135,7 +190,7 @@ export const sharedTreeTutorial: TutorialModule = { "Define a schema for a todo application. You need a `TodoItem` with `title` (string) and `completed` (boolean) fields, and a `TodoList` with `title` (string) and `items` (array of TodoItem).", activeFile: "/App.tsx", files: { - "/App.tsx": `import React from "react"; + "/App.tsx": `${importsBase} // TODO: Import SchemaFactory from "fluid-framework" @@ -156,8 +211,7 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ 'Import: `import { SchemaFactory } from "fluid-framework";`', @@ -168,7 +222,8 @@ export default function App() { validationPatterns: [ { label: "Import SchemaFactory", - pattern: "import\\s*\\{[^}]*SchemaFactory[^}]*\\}\\s*from\\s*[\"']fluid-framework[\"']", + pattern: + "import\\s*\\{[^}]*SchemaFactory[^}]*\\}\\s*from\\s*[\"']fluid-framework[\"']", }, { label: "Define TodoItem with title and completed", @@ -183,20 +238,8 @@ export default function App() { pattern: "sf\\.array\\s*\\(\\s*TodoItem\\s*\\)", }, ], - solution: `import React from "react"; -import { SchemaFactory } from "fluid-framework"; - -const sf = new SchemaFactory("todo-app"); - -const TodoItem = sf.object("TodoItem", { - title: sf.string, - completed: sf.boolean, -}); - -const TodoList = sf.object("TodoList", { - title: sf.string, - items: sf.array(TodoItem), -}); + solution: `${importsWithSchema} +${schemaBlock} export default function App() { return ( @@ -217,18 +260,7 @@ export default function App() { files: { "/App.tsx": `import React from "react"; import { SchemaFactory, TreeViewConfiguration } from "fluid-framework"; - -const sf = new SchemaFactory("todo-app"); - -const TodoItem = sf.object("TodoItem", { - title: sf.string, - completed: sf.boolean, -}); - -const TodoList = sf.object("TodoList", { - title: sf.string, - items: sf.array(TodoItem), -}); +${schemaBlock} // TODO: Add createIndependentTreeBeta to your import from "fluid-framework" @@ -255,19 +287,19 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ 'Add to your import: `import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework";`', "Create tree: `const tree = createIndependentTreeBeta();`", "Create view: `const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList }));`", - "Initialize with plain objects: `view.initialize({ title: \"My Todos\", items: [...] })`", + 'Initialize with plain objects: `view.initialize({ title: "My Todos", items: [...] })`', ], validationPatterns: [ { label: "Import createIndependentTreeBeta", - pattern: "import\\s*\\{[^}]*createIndependentTreeBeta[^}]*\\}\\s*from\\s*[\"']fluid-framework", + pattern: + "import\\s*\\{[^}]*createIndependentTreeBeta[^}]*\\}\\s*from\\s*[\"']fluid-framework", }, { label: "Create tree", @@ -282,31 +314,9 @@ export default function App() { pattern: "view\\.initialize\\s*\\(", }, ], - solution: `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("todo-app"); - -const TodoItem = sf.object("TodoItem", { - title: sf.string, - completed: sf.boolean, -}); - -const TodoList = sf.object("TodoList", { - title: sf.string, - items: sf.array(TodoItem), -}); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); -view.initialize({ - title: "My Todos", - items: [ - { title: "Learn SharedTree schema", completed: true }, - { title: "Build a todo app", completed: false }, - { title: "Add reactive updates", completed: false }, - ], -}); + solution: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} export default function App() { return ( @@ -325,31 +335,9 @@ export default function App() { "Read data from the SharedTree and display it. Iterate over `view.root.items` to render each todo item with its title and completion status.", activeFile: "/App.tsx", files: { - "/App.tsx": `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("todo-app"); - -const TodoItem = sf.object("TodoItem", { - title: sf.string, - completed: sf.boolean, -}); - -const TodoList = sf.object("TodoList", { - title: sf.string, - items: sf.array(TodoItem), -}); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); -view.initialize({ - title: "My Todos", - items: [ - { title: "Learn SharedTree schema", completed: true }, - { title: "Build a todo app", completed: false }, - { title: "Add reactive updates", completed: false }, - ], -}); + "/App.tsx": `${importsWithTree} +${schemaBlock} +${treeSetupBlock} export default function App() { const todoList = view.root; @@ -367,13 +355,12 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ - "Use: `
        {todoList.items.map((item, i) => ...)}
      `", + 'Use: `
        {todoList.items.map((item, i) => ...)}
      `', "Each list item: `
    • `", - "Add checkbox: ``", + 'Add checkbox: ``', "Add title: `{item.title}`", ], validationPatterns: [ @@ -390,51 +377,13 @@ export default function App() { pattern: "item\\.completed", }, ], - solution: `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("todo-app"); - -const TodoItem = sf.object("TodoItem", { - title: sf.string, - completed: sf.boolean, -}); - -const TodoList = sf.object("TodoList", { - title: sf.string, - items: sf.array(TodoItem), -}); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); -view.initialize({ - title: "My Todos", - items: [ - { title: "Learn SharedTree schema", completed: true }, - { title: "Build a todo app", completed: false }, - { title: "Add reactive updates", completed: false }, - ], -}); + solution: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} export default function App() { const todoList = view.root; - - return ( -
      -

      {todoList.title}

      -
        - {todoList.items.map((item, i) => ( -
      • - - {item.title} -
      • - ))} -
      -
      - ); +${todoListRendering} } `, }, @@ -445,31 +394,9 @@ export default function App() { "Make the todos interactive! Toggle `item.completed` when a checkbox is clicked, and add a form to insert new items using `todoList.items.insertAtEnd()`. Pass a plain object to `insertAtEnd()` \u2014 SharedTree matches it to the TodoItem schema automatically.", activeFile: "/App.tsx", files: { - "/App.tsx": `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("todo-app"); - -const TodoItem = sf.object("TodoItem", { - title: sf.string, - completed: sf.boolean, -}); - -const TodoList = sf.object("TodoList", { - title: sf.string, - items: sf.array(TodoItem), -}); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); -view.initialize({ - title: "My Todos", - items: [ - { title: "Learn SharedTree schema", completed: true }, - { title: "Build a todo app", completed: false }, - { title: "Add reactive updates", completed: false }, - ], -}); + "/App.tsx": `${importsWithTree} +${schemaBlock} +${treeSetupBlock} export default function App() { const todoList = view.root; @@ -503,14 +430,13 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ "Toggle: `const handleToggle = (item) => { item.completed = !item.completed; };`", "On checkbox: `onChange={() => handleToggle(item)}` \u2014 and remove `readOnly`", "Insert: `todoList.items.insertAtEnd({ title: newTitle, completed: false });`", - "Form: `
      `", + 'Form: `
      `', ], validationPatterns: [ { @@ -526,37 +452,15 @@ export default function App() { pattern: ']*type\\s*=\\s*["\']text["\']', }, ], - solution: `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("todo-app"); - -const TodoItem = sf.object("TodoItem", { - title: sf.string, - completed: sf.boolean, -}); - -const TodoList = sf.object("TodoList", { - title: sf.string, - items: sf.array(TodoItem), -}); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); -view.initialize({ - title: "My Todos", - items: [ - { title: "Learn SharedTree schema", completed: true }, - { title: "Build a todo app", completed: false }, - { title: "Add reactive updates", completed: false }, - ], -}); + solution: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} export default function App() { const todoList = view.root; const [newTitle, setNewTitle] = React.useState(""); - const handleToggle = (item: any) => { + const handleToggle = (item: typeof TodoItem.Type) => { item.completed = !item.completed; }; @@ -614,31 +518,9 @@ export default function App() { 'Now for the magic of Fluid! Extract a `TodoPanel` component from your App, add `Tree.on(view.root, "treeChanged", callback)` to subscribe to changes, and render **two** panels side by side. Both share the same SharedTree, so when either client toggles or adds a todo, both update instantly. This simulates the multi-client experience.', activeFile: "/App.tsx", files: { - "/App.tsx": `import React from "react"; -import { SchemaFactory, TreeViewConfiguration, Tree, createIndependentTreeBeta } from "fluid-framework"; - -const sf = new SchemaFactory("todo-app"); - -const TodoItem = sf.object("TodoItem", { - title: sf.string, - completed: sf.boolean, -}); - -const TodoList = sf.object("TodoList", { - title: sf.string, - items: sf.array(TodoItem), -}); - -const tree = createIndependentTreeBeta(); -const view = tree.viewWith(new TreeViewConfiguration({ schema: TodoList })); -view.initialize({ - title: "My Todos", - items: [ - { title: "Learn SharedTree schema", completed: true }, - { title: "Build a todo app", completed: false }, - { title: "Add reactive updates", completed: false }, - ], -}); + "/App.tsx": `${importsWithTreeAndEvents} +${schemaBlock} +${treeSetupBlock} // TODO: Extract a TodoPanel component that accepts a { title: string } prop. // Move the todo rendering logic here and add Tree.on for reactivity: @@ -656,8 +538,7 @@ export default function App() { ); } `, - "/main.tsx": mainTsx, - "/styles.css": stylesCss, + ...scaffoldFiles, }, hints: [ "Create `function TodoPanel({ title }: { title: string })` and move the todo UI into it", @@ -668,7 +549,8 @@ export default function App() { validationPatterns: [ { label: 'Tree.on subscription with "treeChanged"', - pattern: 'Tree\\.on\\s*\\(\\s*view\\.root\\s*,\\s*["\']treeChanged["\']', + pattern: + 'Tree\\.on\\s*\\(\\s*view\\.root\\s*,\\s*["\']treeChanged["\']', }, { label: "useEffect for subscription", @@ -683,31 +565,9 @@ export default function App() { pattern: ".* { + const handleToggle = (item: typeof TodoItem.Type) => { item.completed = !item.completed; }; diff --git a/docs/src/css/custom.scss b/docs/src/css/custom.scss index 8cc4da90b45f..fcb10ac60c8d 100644 --- a/docs/src/css/custom.scss +++ b/docs/src/css/custom.scss @@ -54,6 +54,6 @@ } // Applies text-decoration to all links in markdown/MDX content -main a { +main a:not(.breadcrumbs__link) { text-decoration: underline; } diff --git a/docs/src/css/playground.css b/docs/src/css/playground.css index 45d60fb38620..47f8b64139ad 100644 --- a/docs/src/css/playground.css +++ b/docs/src/css/playground.css @@ -33,14 +33,14 @@ font-family: inherit; font-size: inherit; color: inherit; - text-decoration: none; + text-decoration: none !important; } .ffcom-playground-module-card:hover { border-color: var(--ifm-color-primary); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); color: inherit; - text-decoration: none; + text-decoration: none !important; } .ffcom-playground-module-card-header { @@ -262,13 +262,49 @@ margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--ifm-color-emphasis-200); - flex-wrap: wrap; gap: 8px; } +.ffcom-playground-nav-arrow { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 50%; + border: 1px solid var(--ifm-color-emphasis-300); + background: transparent; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: var(--ifm-color-content); + transition: + background 0.2s, + border-color 0.2s; +} + +.ffcom-playground-nav-arrow:hover:not(:disabled) { + background: var(--ifm-color-emphasis-100); +} + +.ffcom-playground-nav-arrow:disabled, +.ffcom-playground-nav-arrow--disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.ffcom-playground-nav-arrow--placeholder { + visibility: hidden; +} + .ffcom-playground-nav-group { display: flex; + flex-wrap: wrap; + justify-content: center; gap: 8px; + flex: 1; + min-width: 0; } .ffcom-playground-nav-button { @@ -282,6 +318,7 @@ background 0.2s, border-color 0.2s; font-family: inherit; + text-decoration: none !important; } .ffcom-playground-nav-primary { @@ -292,6 +329,8 @@ .ffcom-playground-nav-primary:hover { background: var(--ifm-color-primary-dark); + color: white; + text-decoration: none; } .ffcom-playground-nav-secondary { From 98a4022ac120f5d47d619fd44d2b3f5af0796cb7 Mon Sep 17 00:00:00 2001 From: jikim-msft Date: Fri, 13 Mar 2026 18:21:49 -0700 Subject: [PATCH 5/6] refactor --- docs/src/components/playground/StepGuide.tsx | 10 ++- .../playground/TutorialPlayground.tsx | 15 +++- .../playground/data/diceRollerTutorial.ts | 65 +++++--------- .../components/playground/data/sharedFiles.ts | 85 +++++++++++++++++++ .../playground/data/sharedTreeTutorial.ts | 59 +++++-------- docs/src/css/playground.css | 22 ++--- 6 files changed, 153 insertions(+), 103 deletions(-) diff --git a/docs/src/components/playground/StepGuide.tsx b/docs/src/components/playground/StepGuide.tsx index 78f09ea4f33e..06493ace4411 100644 --- a/docs/src/components/playground/StepGuide.tsx +++ b/docs/src/components/playground/StepGuide.tsx @@ -54,6 +54,11 @@ export interface StepGuideProps { */ onToggleSolution: () => void; + /** + * URL path to the tutorial module index page (for "Back to Tutorials" link). + */ + moduleIndexUrl: string; + /** * Callback to reset the current step to boilerplate. */ @@ -88,6 +93,7 @@ export function StepGuide({ validationResults, showSolution, completedSteps, + moduleIndexUrl, onNavigate, onToggleSolution, onResetStep, @@ -199,7 +205,7 @@ export function StepGuide({ {currentStepIndex === totalSteps - 1 && allPassed && ( Back to Tutorials @@ -208,7 +214,7 @@ export function StepGuide({ {currentStepIndex < totalSteps - 1 ? (