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/index.mdx b/docs/docs/start/interactive-tutorial/index.mdx new file mode 100644 index 000000000000..ffa781f2d04d --- /dev/null +++ b/docs/docs/start/interactive-tutorial/index.mdx @@ -0,0 +1,14 @@ +--- +title: Interactive Tutorial +sidebar_position: 5 +hide_table_of_contents: true +--- + +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/package.json b/docs/package.json index 25b4e900d0c2..c5d5d9f50613 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 e7d2798b91d7..931dbb31044c 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -22,6 +22,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) @@ -976,6 +982,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'} @@ -1543,6 +1591,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==} @@ -1670,6 +1739,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'} @@ -1786,6 +1858,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==} @@ -1850,6 +1932,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'} @@ -2666,6 +2751,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==} @@ -2909,6 +2997,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'} @@ -3065,6 +3156,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'} @@ -3314,6 +3408,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'} @@ -3604,6 +3701,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==} @@ -3997,10 +4098,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==} @@ -4011,6 +4123,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'} @@ -4181,6 +4296,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} @@ -4250,6 +4369,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==} @@ -4275,6 +4397,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'} @@ -4996,6 +5121,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==} @@ -5637,6 +5766,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'} @@ -6096,6 +6229,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==} @@ -6254,6 +6390,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'} @@ -7051,6 +7190,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: @@ -7076,6 +7218,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'} @@ -7676,6 +7821,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'} @@ -7698,6 +7846,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'} @@ -7784,6 +7935,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==} @@ -8037,6 +8191,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'} @@ -8331,6 +8488,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'} @@ -9746,6 +9906,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 @@ -11012,6 +11282,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 @@ -11269,6 +11569,8 @@ snapshots: dependencies: '@oclif/core': 4.0.33 + '@open-draft/deferred-promise@2.2.0': {} + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -11353,6 +11655,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)': @@ -11429,6 +11741,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 @@ -12430,6 +12744,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 @@ -12730,6 +13046,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: {} @@ -12896,6 +13217,8 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + clean-set@1.1.2: {} + clean-stack@2.2.0: {} clean-stack@3.0.1: @@ -13135,6 +13458,8 @@ snapshots: optionalDependencies: typescript: 5.5.4 + crelt@1.0.6: {} + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -13470,6 +13795,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 @@ -14055,8 +14385,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 @@ -14073,6 +14421,8 @@ snapshots: escalade@3.2.0: {} + escape-carriage@1.3.1: {} + escape-goat@4.0.0: {} escape-html@1.0.3: {} @@ -14331,6 +14681,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 @@ -14405,6 +14762,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 @@ -14471,6 +14833,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 @@ -15337,6 +15703,8 @@ snapshots: interpret@1.4.0: {} + intersection-observer@0.10.0: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -15914,6 +16282,8 @@ snapshots: lunr@2.3.9: {} + lz-string@1.5.0: {} + make-dir@1.3.0: dependencies: pify: 3.0.0 @@ -16721,6 +17091,8 @@ snapshots: neo-async@2.6.2: {} + next-tick@1.1.0: {} + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -16896,6 +17268,8 @@ snapshots: os-tmpdir@1.0.2: {} + outvariant@1.4.0: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -17760,6 +18134,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 @@ -17789,6 +18167,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 @@ -18574,6 +18954,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: {} @@ -18591,6 +18978,8 @@ snapshots: dependencies: duplexer: 0.1.2 + strict-event-emitter@0.4.6: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -18709,6 +19098,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 @@ -18947,6 +19338,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 @@ -19360,6 +19753,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..12cbb7422eae --- /dev/null +++ b/docs/src/components/playground/ModuleSelector.tsx @@ -0,0 +1,42 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import Link from "@docusaurus/Link"; +import React from "react"; + +import { moduleList } from "./data/modules"; + +import "@site/src/css/playground.css"; + +/** + * Renders a card grid for selecting a tutorial module. + * Each card links to the module's dedicated sub-page. + */ +export function ModuleSelector(): React.ReactElement { + return ( +
+ {moduleList.map((mod) => ( + +
+

{mod.title}

+ + {mod.difficulty} + +
+

{mod.description}

+
+ {mod.steps.length} steps +
+ + ))} +
+ ); +} diff --git a/docs/src/components/playground/PlaygroundWorkspace.tsx b/docs/src/components/playground/PlaygroundWorkspace.tsx new file mode 100644 index 000000000000..4d300380058d --- /dev/null +++ b/docs/src/components/playground/PlaygroundWorkspace.tsx @@ -0,0 +1,162 @@ +/*! + * 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 syncs external file changes into the live Sandpack + * instance and reports code edits back to the parent via {@link onCodeChange}. + * + * By calling `sandpack.updateFile` imperatively we avoid destroying and + * recreating the SandpackProvider (which would re-download and re-bundle every + * npm dependency from scratch — the main cause of ~60 s load times). + */ +function SandpackBridge({ + files, + activeFile, + onCodeChange, +}: { + files: SandpackFiles; + activeFile: string; + onCodeChange: (code: string) => void; +}): null { + const { sandpack } = useSandpack(); + + // Track previous files so we only call updateFile when they actually change. + const prevFilesRef = React.useRef(files); + + React.useEffect(() => { + if (prevFilesRef.current === files) return; + prevFilesRef.current = files; + + // Push every file into the running sandbox. + for (const [path, content] of Object.entries(files)) { + const code = typeof content === "string" ? content : content.code; + sandpack.updateFile(path, code); + } + + // Switch to the requested active file. + sandpack.setActiveFile(activeFile); + }, [files, activeFile, sandpack]); + + // Report edits back for validation. + 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. + * + * IMPORTANT: This component keeps a single SandpackProvider mounted for the + * lifetime of the tutorial. Step changes push new files via the imperative + * `updateFile` API so the bundler stays warm and dependencies are not + * re-downloaded. + */ +export function PlaygroundWorkspace({ + files, + activeFile, + dependencies, + onCodeChange, +}: PlaygroundWorkspaceProps): React.ReactElement { + // Initial files are used only for the first mount of SandpackProvider. + const initialFilesRef = React.useRef({ "/index.html": indexHtml, ...files }); + const customSetup = React.useMemo(() => ({ dependencies }), [dependencies]); + + const options = React.useMemo( + () => ({ + activeFile, + visibleFiles: [activeFile], + recompileMode: "delayed" as const, + recompileDelay: 500, + }), + [activeFile], + ); + + // Merge index.html into the file map so the bridge pushes it too. + const allFiles = React.useMemo( + () => ({ "/index.html": indexHtml, ...files }), + [files], + ); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/docs/src/components/playground/StepGuide.tsx b/docs/src/components/playground/StepGuide.tsx new file mode 100644 index 000000000000..06493ace4411 --- /dev/null +++ b/docs/src/components/playground/StepGuide.tsx @@ -0,0 +1,231 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import Link from "@docusaurus/Link"; +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; + + /** + * Set of step indices that have been completed. + */ + completedSteps: Set; + + /** + * Callback when user navigates to a step. + */ + onNavigate: (stepIndex: number) => void; + + /** + * Callback to toggle showing the solution. + */ + 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. + */ + 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; + }); +} + +/** + * Renders the step instructions, hints, validation checklist, and navigation. + */ +export function StepGuide({ + step, + currentStepIndex, + totalSteps, + validationResults, + showSolution, + completedSteps, + moduleIndexUrl, + onNavigate, + onToggleSolution, + onResetStep, +}: StepGuideProps): React.ReactElement { + const [expandedHints, setExpandedHints] = React.useState>(new Set()); + + const toggleHint = (index: number): void => { + setExpandedHints((prev: Set) => { + 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 = + step.validationPatterns.length === 0 || + (validationResults.length > 0 && validationResults.every(Boolean)); + + return ( +
+ + +

{step.title}

+

{renderInlineMarkdown(step.description)}

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

Hints

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

Checklist

+ {step.validationPatterns.map((pattern, i) => ( + + ))} + {allPassed && ( +
+ All checks passed! +
+ )} +
+ )} + +
+ + +
+ {step.solution !== undefined && ( + + )} + + + + {currentStepIndex === totalSteps - 1 && allPassed && ( + + Back to Tutorials + + )} +
+ + {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..a7abaf35e3b7 --- /dev/null +++ b/docs/src/components/playground/StepIndicator.tsx @@ -0,0 +1,57 @@ +/*! + * 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; + + /** + * Set of step indices that have been completed. + */ + completedSteps?: Set; +} + +/** + * Renders a step progress indicator bar. + */ +export function StepIndicator({ + currentStep, + totalSteps, + completedSteps, +}: 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..5660b4878521 --- /dev/null +++ b/docs/src/components/playground/TutorialPlayground.tsx @@ -0,0 +1,197 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import React from "react"; + +import { PlaygroundWorkspace } from "./PlaygroundWorkspace"; +import { StepGuide } from "./StepGuide"; +import { modulesById } from "./data/modules"; +import type { ValidationPattern } from "./data/types"; + +import "@site/src/css/playground.css"; + +/** + * Path to the tutorial module index page (used for "Back to Tutorials" link). + */ +const MODULE_INDEX_PATH = "/docs/start/interactive-tutorial/"; + +/** + * Runs validation patterns against code, stripping comments first. + * Returns false for any pattern that fails to compile as a regex. + */ +function runValidation(code: string, patterns: ValidationPattern[]): boolean[] { + const stripped = code + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, ""); + return patterns.map((vp) => { + try { + const regex = new RegExp(vp.pattern, vp.flags ?? "s"); + return regex.test(stripped); + } catch { + return false; + } + }); +} + +/** + * {@link TutorialPlayground} component props. + */ +export interface TutorialPlaygroundProps { + /** + * The module to render (e.g. "dice-roller" or "shared-tree-todo"). + */ + moduleId: string; +} + +/** + * Interactive tutorial playground for a single module. + * + * @remarks + * Manages step navigation, code validation, and solution display for the + * given module. Module selection is handled at the page level via Docusaurus + * routing and the {@link ModuleSelector} component. + */ +export function TutorialPlayground({ + moduleId, +}: TutorialPlaygroundProps): React.ReactElement { + const [currentStepIndex, setCurrentStepIndex] = React.useState(0); + const [validationResults, setValidationResults] = React.useState([]); + const [showSolution, setShowSolution] = React.useState(false); + const [resetCounter, setResetCounter] = React.useState(0); + + // Per-step saved code (user's own edits or last editor state) + const codeSnapshotsRef = React.useRef>(new Map()); + + // Saves user's code right before showing solution, for Hide Solution restore + const preSolutionCodeRef = React.useRef(undefined); + + // Track which steps have been completed (all validations passed) + const completedStepsRef = React.useRef>(new Set()); + + const selectedModule = modulesById[moduleId]; + const currentStep = selectedModule?.steps[currentStepIndex]; + + // Always tracks the latest editor code so handleNavigate can snapshot it. + const latestCodeRef = React.useRef(""); + + const validateCode = React.useCallback( + (code: string) => { + if (currentStep === undefined) return; + + latestCodeRef.current = code; + + const results = runValidation(code, currentStep.validationPatterns); + setValidationResults(results); + + if (results.length > 0 && results.every(Boolean)) { + completedStepsRef.current.add(currentStepIndex); + } + }, + [currentStep, currentStepIndex], + ); + + const handleNavigate = (stepIndex: number): void => { + // Save whatever is in the editor right now for this step. + codeSnapshotsRef.current.set(currentStepIndex, latestCodeRef.current); + // Clear pre-solution ref on navigation + preSolutionCodeRef.current = undefined; + + // Pre-seed validation for the target step to avoid flash of unchecked items + const targetStep = selectedModule?.steps[stepIndex]; + const targetSnapshot = codeSnapshotsRef.current.get(stepIndex); + if (targetStep !== undefined && targetSnapshot !== undefined) { + const preSeeded = runValidation(targetSnapshot, targetStep.validationPatterns); + setValidationResults(preSeeded); + } else { + setValidationResults([]); + } + + setCurrentStepIndex(stepIndex); + setShowSolution(false); + }; + + const handleToggleSolution = (): void => { + if (!showSolution) { + // Showing solution: save current code for later restore + preSolutionCodeRef.current = latestCodeRef.current; + codeSnapshotsRef.current.set(currentStepIndex, latestCodeRef.current); + setShowSolution(true); + } else { + // Hiding solution: restore user's pre-solution code + if (preSolutionCodeRef.current !== undefined) { + codeSnapshotsRef.current.set(currentStepIndex, preSolutionCodeRef.current); + } + preSolutionCodeRef.current = undefined; + setShowSolution(false); + } + }; + + const handleResetStep = (): void => { + codeSnapshotsRef.current.delete(currentStepIndex); + preSolutionCodeRef.current = undefined; + completedStepsRef.current.delete(currentStepIndex); + setShowSolution(false); + setValidationResults([]); + setResetCounter((c: number) => c + 1); + }; + + // Build the file map for the current step. + // Priority: solution (if toggled) > saved snapshot > default template. + // + // IMPORTANT: The snapshot ref is intentionally read inside the memo function + // but NOT listed in the deps array. This ensures the memo only recomputes + // when the step or solution state changes (which are the only times we need + // new files). During normal typing, setValidationResults triggers re-renders + // but the memo returns its cached value, keeping the files reference stable + // so Sandpack doesn't reset. + const files = React.useMemo( + () => { + if (currentStep === undefined) { + return {}; + } + if (showSolution && currentStep.solution !== undefined) { + return { ...currentStep.files, [currentStep.activeFile]: currentStep.solution }; + } + const snapshot = codeSnapshotsRef.current.get(currentStepIndex); + if (snapshot !== undefined) { + return { ...currentStep.files, [currentStep.activeFile]: snapshot }; + } + return currentStep.files; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- codeSnapshotsRef read intentionally excluded; see comment above + [showSolution, currentStep, currentStepIndex, resetCounter], + ); + + if (selectedModule === undefined || currentStep === undefined) { + return ( +
+

Unknown tutorial module: {moduleId}

+
+ ); + } + + 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..975097f804d6 --- /dev/null +++ b/docs/src/components/playground/data/diceRollerTutorial.ts @@ -0,0 +1,501 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + createTreeCallPattern, + createTreeImportPattern, + importsBase, + importsWithSchema, + importsWithTree, + importsWithTreeAndEvents, + initializePattern, + mainTsx, + schemaFactoryImportPattern, + treeViewConfigPattern, + useEffectPattern, + viewWithPattern, +} from "./sharedFiles"; +import type { TutorialModule } from "./types"; + +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; +} +`; + +// --- Tutorial-specific code fragments --- + +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", + 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": `${importsBase} + +// 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!

+
+ ); +} +`, + ...scaffoldFiles, + }, + 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: [ + schemaFactoryImportPattern, + { + label: "Create SchemaFactory instance", + pattern: "new\\s+SchemaFactory\\s*\\(", + }, + { + label: "Define Dice with value field", + pattern: "sf\\.(object|objectRecursive)\\s*\\(\\s*[\"']Dice[\"']", + }, + ], + solution: `${importsWithSchema} +${schemaBlock} + +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": `${importsWithSchema} +${schemaBlock} + +// 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!

+
+ ); +} +`, + ...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: Dice }));`", + "Initialize: `view.initialize({ value: 1 });`", + ], + validationPatterns: [ + createTreeImportPattern, + treeViewConfigPattern, + createTreeCallPattern, + viewWithPattern, + initializePattern, + ], + solution: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} + +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": `${importsWithTree} +${schemaBlock} +${treeSetupBlock} +${diceFacesBlock} + +// 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 */} +
+ ); +} +`, + ...scaffoldFiles, + }, + 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": `${importsWithTree} +${schemaBlock} +${treeSetupBlock} +${diceFacesBlock} + +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

+ +
+ ); +} +`, + ...scaffoldFiles, + }, + hints: [ + "Add a ` +
+ ); +} + +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" */} +
+
+ ); +} +`, + ...scaffoldFiles, + }, + 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["\']', + }, + useEffectPattern, + { + 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/modules.ts b/docs/src/components/playground/data/modules.ts new file mode 100644 index 000000000000..6f7a47ddada8 --- /dev/null +++ b/docs/src/components/playground/data/modules.ts @@ -0,0 +1,21 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { diceRollerTutorial } from "./diceRollerTutorial"; +import { sharedTreeTutorial } from "./sharedTreeTutorial"; +import type { TutorialModule } from "./types"; + +/** + * Registry of all tutorial modules, keyed by module id. + */ +export const modulesById: Record = { + "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..0b5862d0b83a --- /dev/null +++ b/docs/src/components/playground/data/sharedFiles.ts @@ -0,0 +1,102 @@ +/*! + * 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(); +`; + +// --- Shared import fragments for composing tutorial step files --- + +/** + * Bare React import. + */ +export const importsBase = `import React from "react";`; + +/** + * React + SchemaFactory import. + */ +export const importsWithSchema = `import React from "react"; +import { SchemaFactory } from "fluid-framework";`; + +/** + * React + SchemaFactory + tree creation imports. + */ +export const importsWithTree = `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, createIndependentTreeBeta } from "fluid-framework";`; + +/** + * React + SchemaFactory + tree creation + Tree event imports. + */ +export const importsWithTreeAndEvents = `import React from "react"; +import { SchemaFactory, TreeViewConfiguration, Tree, createIndependentTreeBeta } from "fluid-framework";`; + +// --- Shared validation patterns used across tutorials --- + +/** + * Validation pattern that checks for a SchemaFactory import from fluid-framework. + */ +export const schemaFactoryImportPattern = { + label: "Import SchemaFactory", + pattern: + "import\\s*\\{[^}]*SchemaFactory[^}]*\\}\\s*from\\s*[\"']fluid-framework[\"']", +}; + +/** + * Validation pattern that checks for a createIndependentTreeBeta import. + */ +export const createTreeImportPattern = { + label: "Import createIndependentTreeBeta", + pattern: + "import\\s*\\{[^}]*createIndependentTreeBeta[^}]*\\}\\s*from\\s*[\"']fluid-framework", +}; + +/** + * Validation pattern that checks for TreeViewConfiguration usage. + */ +export const treeViewConfigPattern = { + label: "Import TreeViewConfiguration", + pattern: "TreeViewConfiguration", +}; + +/** + * Validation pattern that checks for tree creation. + */ +export const createTreeCallPattern = { + label: "Create tree", + pattern: "createIndependentTreeBeta\\s*\\(", +}; + +/** + * Validation pattern that checks for viewWith usage. + */ +export const viewWithPattern = { + label: "Create view with viewWith", + pattern: "\\.viewWith\\s*\\(", +}; + +/** + * Validation pattern that checks for view initialization. + */ +export const initializePattern = { + label: "Initialize the tree", + pattern: "view\\.initialize\\s*\\(", +}; + +/** + * Validation pattern that checks for useEffect usage. + */ +export const useEffectPattern = { + label: "useEffect for subscription", + pattern: "useEffect", +}; diff --git a/docs/src/components/playground/data/sharedTreeTutorial.ts b/docs/src/components/playground/data/sharedTreeTutorial.ts new file mode 100644 index 000000000000..2d2bdeb5686f --- /dev/null +++ b/docs/src/components/playground/data/sharedTreeTutorial.ts @@ -0,0 +1,628 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + createTreeCallPattern, + createTreeImportPattern, + importsBase, + importsWithSchema, + importsWithTree, + importsWithTreeAndEvents, + initializePattern, + mainTsx, + schemaFactoryImportPattern, + useEffectPattern, + viewWithPattern, +} from "./sharedFiles"; +import type { TutorialModule } from "./types"; + +const stylesCss = `body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 0; + padding: 16px; + background: #f5f5f5; +} + +.todo-container { + display: flex; + gap: 16px; + flex-wrap: wrap; + justify-content: center; +} + +.todo-container > h3 { + width: 100%; + text-align: center; + color: #666; + margin: 0 0 4px; +} + +.todo-app { + max-width: 500px; + flex: 1; + min-width: 260px; + 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; +} +`; + +// --- Tutorial-specific code fragments --- + +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", + 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": `${importsBase} + +// 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!

+
+ ); +} +`, + ...scaffoldFiles, + }, + 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: [ + schemaFactoryImportPattern, + { + 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: `${importsWithSchema} +${schemaBlock} + +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"; +${schemaBlock} + +// 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!

+
+ ); +} +`, + ...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: [...] })`', + ], + validationPatterns: [ + createTreeImportPattern, + createTreeCallPattern, + viewWithPattern, + { ...initializePattern, label: "Initialize with sample data" }, + ], + solution: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} + +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": `${importsWithTree} +${schemaBlock} +${treeSetupBlock} + +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 + */} +
+ ); +} +`, + ...scaffoldFiles, + }, + 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: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} + +export default function App() { + const todoList = view.root; +${todoListRendering} +} +`, + }, + { + 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": `${importsWithTree} +${schemaBlock} +${treeSetupBlock} + +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 */} +
    + ); +} +`, + ...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: `
    `', + ], + 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: `${importsWithTree} +${schemaBlock} +${treeSetupBlock} + +export default function App() { + const todoList = view.root; + const [newTitle, setNewTitle] = React.useState(""); + + const handleToggle = (item: typeof TodoItem.Type) => { + 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: Two-Client Sync", + description: + '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": `${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: +// 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 ( +
    +

    Both panels share the same SharedTree!

    + {/* TODO: Render TWO TodoPanel components with different titles */} + {/* e.g., "Client A" and "Client B" */} +
    + ); +} +`, + ...scaffoldFiles, + }, + hints: [ + "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: [ + { + label: 'Tree.on subscription with "treeChanged"', + pattern: + 'Tree\\.on\\s*\\(\\s*view\\.root\\s*,\\s*["\']treeChanged["\']', + }, + useEffectPattern, + { + label: "TodoPanel component defined", + pattern: "function\\s+TodoPanel", + }, + { + label: "Two TodoPanel instances rendered", + pattern: ".* { + 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: typeof TodoItem.Type) => { + item.completed = !item.completed; + }; + + const handleAdd = () => { + if (newTitle.trim() === "") return; + todoList.items.insertAtEnd( + { title: newTitle.trim(), completed: false } + ); + setNewTitle(""); + }; + + return ( +
    +

    {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 +
    +
    + ); +} + +export default function App() { + return ( +
    +

    Both panels share the same SharedTree!

    + + +
    + ); +} +`, + }, + ], +}; 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..af6883f05863 --- /dev/null +++ b/docs/src/components/playground/index.ts @@ -0,0 +1,7 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export { ModuleSelector } from "./ModuleSelector"; +export { TutorialPlayground } from "./TutorialPlayground"; 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 new file mode 100644 index 000000000000..d4a912a187f9 --- /dev/null +++ b/docs/src/css/playground.css @@ -0,0 +1,408 @@ +.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; + 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); +} + +.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); + 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 { + 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 { + 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; + text-decoration: none !important; +} + +.ffcom-playground-nav-primary, +.ffcom-playground-nav-active { + background: var(--ifm-color-primary); + color: white; + border-color: var(--ifm-color-primary); +} + +.ffcom-playground-nav-primary:hover, +.ffcom-playground-nav-active:hover { + background: var(--ifm-color-primary-dark); + color: white; +} + +.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-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; + 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: 5; + min-width: 0; + } + + .ffcom-playground-guide { + 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); + overflow-y: auto; + } +}