feat(launcher): rewrite UI
Change-Id: I33e02257a6ad2ee6df1952c179e0f11588c5e48a
diff --git a/core/installer/server/launcher/fake_app_directory.go b/core/installer/server/launcher/fake_app_directory.go
index 216043b..cfa06c0 100644
--- a/core/installer/server/launcher/fake_app_directory.go
+++ b/core/installer/server/launcher/fake_app_directory.go
@@ -1,109 +1,170 @@
package launcher
-type FakeAppDirectory struct {
+import "html/template"
+
+type staticAppDirectory struct {
+ apps []AppLauncherInfo
}
-func (s *FakeAppDirectory) GetAllApps() ([]AppLauncherInfo, error) {
- // TODO(dtabidze): use repoIO to get actual info
- googleInfo := AppLauncherInfo{
- Name: "Memberships",
- Icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 24 24'><path fill='currentColor' d='M15.43 15.48c-1.1-.49-2.26-.73-3.43-.73c-1.18 0-2.33.25-3.43.73c-.23.1-.4.29-.49.52h7.85a.978.978 0 0 0-.5-.52m-2.49-6.69C12.86 8.33 12.47 8 12 8s-.86.33-.94.79l-.2 1.21h2.28z' opacity='0.3'/><path fill='currentColor' d='M10.27 12h3.46a1.5 1.5 0 0 0 1.48-1.75l-.3-1.79a2.951 2.951 0 0 0-5.82.01l-.3 1.79c-.15.91.55 1.74 1.48 1.74m.79-3.21c.08-.46.47-.79.94-.79s.86.33.94.79l.2 1.21h-2.28zm-9.4 2.32c-.13.26-.18.57-.1.88c.16.69.76 1.03 1.53 1h1.95c.83 0 1.51-.58 1.51-1.29c0-.14-.03-.27-.07-.4c-.01-.03-.01-.05.01-.08c.09-.16.14-.34.14-.53c0-.31-.14-.6-.36-.82c-.03-.03-.03-.06-.02-.1c.07-.2.07-.43.01-.65a1.12 1.12 0 0 0-.99-.74a.09.09 0 0 1-.07-.03C5.03 8.14 4.72 8 4.37 8c-.3 0-.57.1-.75.26c-.03.03-.06.03-.09.02a1.24 1.24 0 0 0-1.7 1.03c0 .02-.01.04-.03.06c-.29.26-.46.65-.41 1.05c.03.22.12.43.25.6c.03.02.03.06.02.09m14.58 2.54c-1.17-.52-2.61-.9-4.24-.9c-1.63 0-3.07.39-4.24.9A2.988 2.988 0 0 0 6 16.39V18h12v-1.61c0-1.18-.68-2.26-1.76-2.74M8.07 16a.96.96 0 0 1 .49-.52c1.1-.49 2.26-.73 3.43-.73c1.18 0 2.33.25 3.43.73c.23.1.4.29.49.52zm-6.85-1.42A2.01 2.01 0 0 0 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29c-.37-.06-.74-.1-1.13-.1c-.99 0-1.93.21-2.78.58m21.56 0A6.95 6.95 0 0 0 20 14c-.39 0-.76.04-1.13.1c.4.68.63 1.46.63 2.29V18H24v-1.57c0-.81-.48-1.53-1.22-1.85M22 11v-.5c0-1.1-.9-2-2-2h-2c-.42 0-.65.48-.39.81l.7.63c-.19.31-.31.67-.31 1.06c0 1.1.9 2 2 2s2-.9 2-2'/></svg>",
- Help: []HelpDocumentRendered{
- {
- Title: "Memberships search",
- Contents: `In the realm of coding, every keystroke is a brushstroke on the canvas of innovation. It's a domain where strings of characters dance to the rhythm of logic, constructing intricate digital symphonies. From the crisp lines of Python to the curly braces of JavaScript, each language tells a unique story, weaving together the fabric of our digital world.
+func (d staticAppDirectory) GetAllApps() ([]AppLauncherInfo, error) {
+ return d.apps, nil
+}
- In this labyrinth of code, programmers are modern-day alchemists, transforming abstract ideas into tangible realities. They navigate through the maze of syntax, debugging their creations with the precision of a surgeon. Errors are not failures but rather stepping stones on the path to mastery, each one offering a lesson in resilience and problem-solving.
-
- In the realm of algorithms, complexity is the name of the game. Programmers orchestrate elegant solutions to complex problems, employing a myriad of data structures and algorithms to optimize performance and efficiency. From sorting algorithms that arrange data in perfect harmony to search algorithms that uncover hidden treasures within vast datasets, the possibilities are as boundless as the imagination itself.
-
- But coding is not just about logic and structure; it's also a form of expression. Just as a poet wields words to evoke emotion, a coder crafts lines of code to breathe life into their creations. Whether it's a sleek user interface or a powerful backend system, every project is an opportunity to leave a mark on the digital landscape.
-
- In the collaborative ecosystem of coding, communities flourish and ideas thrive. Open-source repositories serve as the collective knowledge base of humanity, where programmers from around the globe collaborate to build upon each other's work. It's a testament to the power of collective intelligence, where the sum is truly greater than its parts.
-
- As we journey deeper into the digital age, the importance of coding becomes increasingly evident. It's not just a technical skill but a language of the future, a gateway to unlocking the full potential of technology. So let us embrace the beauty of code, for in its intricate tapestry lies the blueprint for tomorrow's innovations.`,
- Children: []HelpDocumentRendered{
- {
- Title: "Nested Child 1",
- Contents: "Nested Child 1 Content",
- Children: nil,
- },
- {
- Title: "Nested Child 2",
- Contents: "Nested Child 2 Content",
- Children: []HelpDocumentRendered{
- {
- Title: "Nested Child 2.1",
- Contents: "Nested Child 2.1 Content",
- Children: []HelpDocumentRendered{
- {
- Title: "Nested Child 2.1.1",
- Contents: "Nested Child 2.1.1 Content",
- Children: nil,
- },
- {
- Title: "Nested Child 2.1.2",
- Contents: "Nested Child 2.1.2 Content",
- Children: nil,
- },
- },
- },
- {
- Title: "Nested Child 2.2",
- Contents: "Nested Child 2.2 Content",
- Children: nil,
- },
- },
- },
- {
- Title: "Nested Child 3",
- Contents: "Nested Child 3 Content",
- Children: nil,
- },
- },
- },
- {
- Title: "Another Memberships search",
- Contents: "Another Memberships Content",
- Children: []HelpDocumentRendered{
- {
- Title: "Another Nested Child 1",
- Contents: "Another Nested Child 1 Content",
- Children: nil,
- },
- {
- Title: "Another Nested Child 2",
- Contents: "Another Nested Child 2 Content",
- Children: nil,
- },
- },
- },
- },
- URL: "https://memberships.p.v1.dodo.cloud",
+var _ AppDirectory = staticAppDirectory{}
+
+func fakeIcon(contents string) template.HTML {
+ return template.HTML(`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">` + contents + `</svg>`)
+}
+
+func fakeHelp(title string, contents template.HTML, children ...HelpDocumentRendered) HelpDocumentRendered {
+ return HelpDocumentRendered{
+ Title: title,
+ Contents: contents,
+ Children: children,
}
- gitInfo := AppLauncherInfo{
- Name: "Gerrit",
- Icon: "<svg fill='#000000' width='50px' height='50px' viewBox='0 0 24 24' role='img' xmlns='http://www.w3.org/2000/svg'><g id='SVGRepo_bgCarrier' stroke-width='0'></g><g id='SVGRepo_tracerCarrier' stroke-linecap='round' stroke-linejoin='round'></g><g id='SVGRepo_iconCarrier'><title>Gerrit icon</title><path d='M12.648 2.678l-.245-.266c.004-.004.29-.268.413-.41.121-.146.342-.484.346-.486l.301.195c-.014.016-.234.359-.375.522-.137.165-.428.432-.44.445zm1.577 10.597c-.012-.004-.24-.154-.365-.221-.117-.059-.32-.146-.422-.191l.213-.612-.898-.444-.286.871c-.174.004-.713.053-1.51.389-.959.4-1.688 1.025-1.695 1.029l-.143.125.641.025.02-.016c.006-.006.721-.535 1.119-.705.088-.037.207-.074.33-.105-.209.105-.439.227-.6.32-.199.119-.57.381-.586.393l-.186.129.682.016.018-.01c.012-.008 1.164-.623 1.789-.76l.196-.047c.145-.037.246-.064.422-.064.156 0 .369.021.688.07.398.059.66.158.664.16l.24.094-.322-.436-.012-.008.003-.002zm-8.98-7.298h-.029l-1.006.916v.03c-.016.266.078.52.258.716.182.196.42.309.686.319h.043c.531 0 .965-.413.992-.94.023-.545-.399-1.01-.944-1.041zM5.2 7.808h-.041c-.117-.005-.23-.032-.33-.085.045.008.098.01.148.002.284-.035.481-.291.448-.575-.035-.279-.293-.48-.576-.442-.264.034-.457.267-.446.531-.031-.09-.045-.183-.045-.28l.912-.833c.446.042.79.431.768.882s-.391.8-.842.8H5.2zm9.329-3.725l-.293-.195-.41.265-.395-.285-.301.18.404.291-.416.27.297.18.4-.254.387.28.309-.169-.407-.296M24 19.525c-.213-.209-.418-.416-.629-.627-.48-.488-.957-.984-1.418-1.486-.547-.598-1.082-1.207-1.582-1.844-.225-.283-.441-.58-.646-.881-.254-.387-.469-.795-.668-1.215-.211-.445-.398-.9-.576-1.356.24.21.463.444.664.692.021-.215.041-.43.059-.648l.023-.322c0-.033.012-.066-.008-.096-.016-.029-.033-.051-.053-.075-.121-.149-.264-.282-.406-.413-.189-.181-.387-.36-.584-.533l-.18-.156c-.027-.027-.057-.051-.086-.074-.031-.03-.041-.096-.055-.136l-.154-.479c.607.285 1.109.741 1.578 1.215.004-.219.004-.436 0-.652 0-.114-.002-.229-.008-.346 0-.029.006-.1-.016-.127-.012-.02-.031-.036-.043-.055-.109-.117-.234-.217-.357-.314-.172-.143-.35-.277-.527-.408-.156-.117-.318-.232-.477-.345-.018-.016-.076-.039-.064-.06l.039-.08c.02-.035.014-.045.053-.029l.17.059c.406.156.779.368 1.143.608-.123-.45-.311-.885-.525-1.296-.389-.762-.893-1.468-1.463-2.105-.379-.426-.785-.824-1.219-1.193-.223-.19-.457-.369-.699-.533-.113-.074-.225-.149-.346-.217-.049-.03-.105-.056-.15-.094-.18-.144-.365-.284-.549-.429.238.098.469.21.689.338-.127-.194-.342-.331-.545-.436-.307-.159-.637-.276-.967-.378.113-.12.234-.228.346-.348.113-.12.223-.246.33-.372.127-.15.252-.3.375-.455l-1.156-.726-.48-.303c-.123.246-.277.476-.458.68-.18.203-.391.369-.6.539-.191.156-.379.316-.566.472-.047.04-.092.085-.145.12-.025.021-.096.004-.127.004H10.7c-.297.006-.596.029-.891.068-.058.007-.113.013-.175.008l-.178-.01c-.137-.008-.271-.016-.408-.016-.289-.004-.58 0-.871.025-.244.022-.489.055-.729.112-.238.056-.461.172-.66.312-.193.141-.387.32-.504.53s-.17.456-.213.689c-.135.013-.268.01-.4.016-.141.008-.277.021-.416.039-.27.037-.535.096-.795.18-.496.154-.945.405-1.336.75-.195.181-.377.38-.539.596-.02.025-.037.051-.063.068-.01.009-.027.016-.033.027-.015.027-.033.052-.051.076l-.134.212c-.168.285-.276.595-.383.906l-.045.123.033-.021c-.01.067-.02.135-.025.202l-.008.105v.053l-.066.013c-.135.026-.271.06-.4.101-.12.039-.233.086-.337.15-.105.066-.195.153-.285.239-.318.31-.562.698-.687 1.128-.1.348-.137.735-.059 1.092.021.09.049.188.107.262.074.09.199.126.313.095.131-.036.248-.124.371-.187l.314-.157c.455-.226.93-.446 1.438-.525.035-.005.1.067.131.091.049.037.1.074.148.104.109.069.221.129.334.181.262.114.541.174.818.231.502.105 1.014.171 1.529.18.207.004.416 0 .625-.018.225-.02.451-.049.678-.051.25-.005.496.025.74.055.254.031.51.068.764.105.518.079 1.031.169 1.543.279-.115.18-.227.362-.338.545-.008.013-.072 0-.088 0-.041-.002-.086-.002-.127 0-.074 0-.15.008-.225.019-.211.03-.416.083-.615.156-.49.181-.938.483-1.326.833-.221.195-.43.408-.609.641-.049.064-.094.129-.139.193.105-.023.211-.045.318-.07.061-.016.117-.027.176-.039.021-.008.029-.016.049-.027.219-.203.451-.393.695-.563.111-.08.225-.154.348-.215.166-.083.354-.131.533-.174-.33.18-.678.35-.977.584-.172.139-.328.291-.49.439l.654-.104c.01 0 .014-.004.023-.01l.17-.094c.113-.064.23-.125.346-.186.24-.123.482-.24.732-.34.236-.094.48-.178.732-.225.221-.046.428-.052.648 0 .342.074.67.232.969.414.021.014.035.033.055.014.016-.014.098-.074.09-.092l-.18-.334c-.016-.029-.023-.049-.051-.066l-.131-.077c-.176-.104-.348-.21-.51-.331-.037-.023-.078-.039-.082-.089-.004-.046.014-.093.029-.136.033-.09.08-.169.131-.248.033-.058.072-.111.109-.166.014-.018.01-.021.031-.014l.119.034c.236.077.469.159.711.22.27.068.545.123.82.176l.048.01c-.056-.03-.091-.09-.132-.136-.023-.03-.035-.05-.074-.06l-.105-.02c-.074-.016-.148-.03-.225-.049-.262-.057-.525-.119-.777-.209-.375-.13-.758-.232-1.145-.322-.486-.107-.977-.194-1.465-.275-.25-.041-.498-.074-.75-.109-.246-.034-.496-.07-.746-.087-.539-.034-1.074.087-1.615.081-.365-.004-.734-.055-1.096-.105l-.068-.009c.252-.093.5-.188.75-.285.236-.095.471-.192.705-.289.127-.052.25-.105.373-.157.113-.051.225-.1.328-.17.408-.279.676-.727.975-1.109.273-.361.586-.7.99-.908.129-.066.262-.117.4-.156-.283-.439-.549-.93-.623-1.455.029.012.061.025.094.037.035.012.064.016.072.045l.029.135c.026.091.053.176.086.262.072.191.16.375.26.551.219.396.484.766.766 1.12.539.686 1.145 1.305 1.736 1.941.145.158.275.324.41.492.15.187.303.375.457.561.121.141.234.285.356.426l.094.112c.016.018.037.026.061.036.604.3 1.213.6 1.822.895.434.209.869.42 1.309.623.236.109.471.219.711.32.029.014.066.029.098.039.016.01.029.016.047.023l.016.049c.027.072.051.145.076.217.049.141.098.279.148.416.33.912.695 1.814 1.145 2.676.346.656.715 1.301 1.09 1.939.4.68.811 1.354 1.225 2.025.332.535.666 1.072 1.008 1.605.146.227.289.459.439.688l.063.094.775-1.141.191-.283c.008-.006.082-.102.076-.109l-.156-.24c-.291-.451-.584-.898-.871-1.35l-.828-1.283-.105-.166.656.799 1.115 1.35c.121.146.236.289.354.438.219-.402.439-.801.662-1.201.086-.164.176-.33.266-.492M12.13 1.915c.309-.246.563-.563.757-.906l1.178.743.18.112c-.246.3-.496.604-.775.873-.035.034-.074.069-.107.104-.021.025-.047.051-.07.075l-.033.038c-.023-.007-.045-.016-.068-.02-.146-.041-.289-.08-.436-.111-.292-.066-.589-.117-.886-.152-.191-.021-.385-.04-.578-.058.275-.234.557-.467.838-.698m-1.754 1.37c.232-.096.482-.15.73-.191.49-.082.994-.094 1.489-.04.354.038.701.108 1.041.21l-.141.115-.225-.061c-.141-.029-.281-.06-.422-.082-.246-.037-.497-.06-.749-.065-.461-.008-.926.042-1.371.159-.301.078-.604.188-.879.332-.286.154-.555.346-.78.582-.134.137-.257.289-.359.455-.057.09-.105.18-.15.275-.024.047-.043.096-.061.144l-.029.075c-.004.015-.008.026-.012.038L8.39 5.22l-.082-.011c.234-.719.763-1.286 1.418-1.649.207-.114.426-.218.65-.279m-3.93.374c.266-.381.686-.649 1.139-.743.502-.101 1.016-.119 1.525-.107-.525.131-1.05.326-1.488.652-.34.075-.664.24-.934.459-.09.071-.17.15-.246.236-.035.037-.066.075-.097.116-.014.015-.027.03-.037.049h-.084c.023-.135.055-.27.098-.4.029-.09.064-.18.119-.26m.882.056c-.145.143-.27.303-.391.469-.047.069-.098.153-.18.191-.085.039-.194-.005-.28-.031.224-.279.521-.494.851-.629M2.308 6.585c.08-.16.182-.315.275-.465.016-.027.053-.046.074-.063.049-.034.096-.069.143-.106.309-.225.621-.446.939-.656.166-.107.33-.213.502-.307.182-.101.369-.18.565-.244.347-.112.707-.189 1.068-.244.174-.025.361-.057.531-.015-.33.188-.658.375-.99.558-.342.191-.689.367-1.035.552-.332.18-.66.375-.981.577-.336.206-.67.419-1.002.629-.08.051-.16.105-.24.155.045-.125.09-.251.151-.371m1.009 3.056c-.029.056-.134.042-.187.042-.094 0-.186 0-.279.006-.258.015-.51.06-.758.138-.475.147-.922.375-1.365.604-.1.055-.203.135-.316.162-.061.015-.131-.008-.166-.063-.035-.061-.053-.131-.064-.198-.027-.12-.035-.245-.033-.368 0-.33.076-.66.215-.96.096-.21.221-.401.371-.574.15-.175.326-.351.539-.45.355-.162.775-.213 1.164-.235.072-.005.145-.007.217-.007.1 0 .217-.016.311.019.074.027.121.105.15.173.045.105.074.225.1.336.061.26.123.525.145.791.01.119.016.239.004.359-.009.073-.014.156-.05.223m2.829-1.973c-.194.123-.403.218-.616.298-.215.077-.436.144-.66.181-.097.013-.195.025-.293.021-.101-.003-.193-.036-.293-.067-.195-.063-.393-.13-.57-.233-.064-.039-.135-.084-.174-.15-.029-.052-.029-.116-.023-.174.012-.231.094-.458.203-.66.187-.343.482-.612.84-.775.879-.396 1.865-.029 2.611.49l.08.061c-.154.165-.311.321-.473.477-.199.189-.404.381-.634.531M8.31 5.739c-.475-.203-.926-.458-1.356-.738.449.188.934.3 1.414.37.258.037.521.072.781.08.281.008.564-.021.84-.075.545-.103 1.068-.305 1.566-.551.494-.245.964-.537 1.413-.859.217-.155.43-.315.633-.487.021-.016.174-.161.184-.154l.041.031.537.416c.328.254.658.51.988.762-.906.326-1.826.629-2.752.904-.519.156-1.038.301-1.565.42-.412.098-.834.189-1.256.21-.507.022-1.006-.135-1.47-.33m8.85 3.942c.076.021.145.045.215.067l.094.033c.016.006.031.015.045.02l.021.06c.045.146.09.289.139.432-.15-.127-.301-.254-.451-.379l-.09-.074c-.021-.016-.045-.021-.029-.046l.059-.114m-.671 1.444l.035-.063.027-.046c.012-.018.008-.022.029-.012.129.054.258.111.385.17.24.11.475.23.703.364.107.065.217.135.322.205l.15.105.074.057c.033.027.041.063.057.102.104.282.219.564.338.844.078.189.162.379.248.566-.293-.371-.621-.715-.957-1.045-.346-.346-.705-.671-1.078-.981l-.323-.264m3.746 6.42l-.121.09.008.016.063.094.271.42.904 1.402c.311.48.621.963.932 1.445l.309.48.084.133c.004.004.029.041.029.045-.26.385-.523.77-.783 1.154-.027.037-.051.076-.074.111-.24-.373-.479-.744-.715-1.117-.4-.635-.795-1.277-1.184-1.916-.434-.709-.855-1.418-1.264-2.141-.383-.674-.75-1.361-1.059-2.076-.301-.697-.563-1.408-.811-2.121.486.193.98.367 1.48.521.145.045.289.09.436.127l.063.018c.008 0 .014-.039.018-.049.018-.064.031-.129.045-.195.031-.125.051-.254.074-.381.205.428.436.844.701 1.236.215.314.445.621.686.92.521.656 1.074 1.283 1.643 1.898.463.494.934.984 1.408 1.465l.389.389c.006.006.039.031.037.041l-.031.053-.148.275-.588 1.068c-.18-.219-.361-.436-.541-.658l-1.125-1.361c-.314-.387-.637-.773-.953-1.16l-.186-.225'></path></g></svg>",
- Help: []HelpDocumentRendered{
- {
- Title: "Gerrit Doc",
- Contents: "Some random Gerrit help.",
- Children: nil,
+}
+
+func NewFakeAppDirectory() staticAppDirectory {
+ return staticAppDirectory{apps: []AppLauncherInfo{
+ {
+ Id: "pairdrop",
+ Name: "PairDrop",
+ Icon: fakeIcon(`<path d="M12 3v12"/><path d="m7 8 5-5 5 5"/><path d="M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("PairDrop quick start", `<p>PairDrop sends files directly between nearby browsers without requiring an account. Keep this page open on both devices and wait for each device to appear.</p><ol><li>Select the receiving device.</li><li>Choose one or more files, or send a short text message.</li><li>Accept the transfer on the receiving device and choose where to save it.</li></ol>`,
+ fakeHelp("PairDrop device discovery", `<p>Devices normally discover each other automatically when they can reach the PairDrop service. If a device is missing, confirm that both browsers are online, refresh both pages, and check whether a VPN or browser privacy extension is blocking peer-to-peer connections.</p>`),
+ fakeHelp("PairDrop transfer privacy", `<p>File contents are transferred between browsers using an encrypted peer-to-peer connection when the network permits it. The signaling service helps the devices connect but is not intended to store transferred files. Avoid closing either tab until the progress indicator completes.</p>`),
+ ),
},
+ URL: "https://pairdrop.net/",
+ DisplayURL: "pairdrop.net",
},
- URL: "https://code.v1.dodo.cloud/dashboard/self",
- }
- forumge := AppLauncherInfo{
- Name: "Forum",
- Icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 256 256'><path fill='currentColor' d='M100 140a8 8 0 1 1-8-8a8 8 0 0 1 8 8Zm64 8a8 8 0 1 0-8-8a8 8 0 0 0 8 8Zm64.94-9.11a12.12 12.12 0 0 1-5 1.11a11.83 11.83 0 0 1-9.35-4.62l-2.59-3.29V184a36 36 0 0 1-36 36H80a36 36 0 0 1-36-36v-51.91l-2.53 3.27A11.88 11.88 0 0 1 32.1 140a12.08 12.08 0 0 1-5-1.11a11.82 11.82 0 0 1-6.84-13.14l16.42-88a12 12 0 0 1 14.7-9.43h.16L104.58 44h46.84l53.08-15.6h.16a12 12 0 0 1 14.7 9.43l16.42 88a11.81 11.81 0 0 1-6.84 13.06ZM97.25 50.18L49.34 36.1a4.18 4.18 0 0 0-.92-.1a4 4 0 0 0-3.92 3.26l-16.42 88a4 4 0 0 0 7.08 3.22ZM204 121.75L150 52h-44l-54 69.75V184a28 28 0 0 0 28 28h44v-18.34l-14.83-14.83a4 4 0 0 1 5.66-5.66L128 186.34l13.17-13.17a4 4 0 0 1 5.66 5.66L132 193.66V212h44a28 28 0 0 0 28-28Zm23.92 5.48l-16.42-88a4 4 0 0 0-4.84-3.16l-47.91 14.11l62.11 80.28a4 4 0 0 0 7.06-3.23Z'/></svg>",
- Help: []HelpDocumentRendered{
- {
- Title: "Forum Doc",
- Contents: "Some random Forum help.",
- Children: nil,
+ {
+ Id: "cyberchef",
+ Name: "CyberChef",
+ Icon: fakeIcon(`<path d="M6 3v6a3 3 0 0 0 3 3V3"/><path d="M7.5 12v9"/><path d="M16 3c-2 2-2 7 0 9v9"/><path d="M16 3v9h2V3"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("CyberChef quick start", `<p>CyberChef is a browser-based toolbox for decoding, encoding, compression, hashing, and data inspection. Paste source material into <strong>Input</strong>, then drag operations into the <strong>Recipe</strong> column. Results update in <strong>Output</strong> as the recipe changes.</p>`,
+ fakeHelp("CyberChef building recipes", `<p>Recipes run from top to bottom. Reorder operations by dragging them, disable a step while debugging, and use the search field to find operations such as <code>From Base64</code>, <code>Gunzip</code>, or <code>SHA2</code>. Save useful recipes as URLs only when the URL does not contain sensitive input.</p>`),
+ fakeHelp("CyberChef sensitive data", `<p>Most operations run locally in the browser, but this public instance is provided only for testing. Do not paste production credentials, private keys, personal records, or other confidential homelab data into a third-party deployment.</p>`),
+ ),
},
+ URL: "https://gchq.github.io/CyberChef/",
+ DisplayURL: "gchq.github.io/CyberChef",
},
- URL: "https://forum.ge",
- }
- return []AppLauncherInfo{googleInfo, gitInfo, forumge}, nil
+ {
+ Id: "mermaid-live",
+ Name: "Mermaid Live",
+ Icon: fakeIcon(`<rect x="3" y="3" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><path d="M6 8v4h12v4M6 12v4"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("Mermaid Live quick start", `<p>Mermaid Live turns plain-text definitions into flowcharts, sequence diagrams, state diagrams, and other technical visuals. Edit the definition on the left and review the rendered diagram on the right.</p><p>Start with <code>flowchart TD</code>, add nodes such as <code>A[Request]</code>, and connect them with <code>A --> B[Response]</code>.</p>`,
+ fakeHelp("Mermaid Live exporting diagrams", `<p>Use the actions above the preview to copy a share link or export SVG and PNG files. SVG is preferred for documentation because it remains sharp at any size and can be edited later.</p>`),
+ fakeHelp("Mermaid Live troubleshooting", `<p>Syntax errors are reported near the preview. Check indentation, unmatched punctuation, and reserved words used as identifiers. Build complex diagrams in small steps so the line that introduced an error is easy to identify.</p>`),
+ ),
+ },
+ URL: "https://mermaid.live/edit",
+ DisplayURL: "mermaid.live",
+ },
+ {
+ Id: "photopea",
+ Name: "Photopea",
+ Icon: fakeIcon(`<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="m4 17 5-5 4 4 2-2 5 4"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("Photopea quick start", `<p>Photopea is a full image editor that runs in the browser and can open PSD, XCF, Sketch, SVG, PDF, and common image formats. Choose <strong>File → Open</strong> or drop a file into the workspace. Documents remain in the browser until you export or save them.</p>`,
+ fakeHelp("Photopea layers and history", `<p>Use the Layers panel to reorder content, change opacity, and apply masks without permanently altering the source. The History panel can step backward through recent edits, but it is not a substitute for saving versions of important work.</p>`),
+ fakeHelp("Photopea exporting files", `<p>Use <strong>File → Save as PSD</strong> when you need to preserve layers. Use <strong>File → Export As</strong> for PNG, JPG, SVG, or WebP output. Review dimensions and quality before downloading large assets.</p>`),
+ ),
+ },
+ URL: "https://www.photopea.com/",
+ DisplayURL: "photopea.com",
+ },
+ {
+ Id: "js-paint",
+ Name: "JS Paint",
+ Icon: fakeIcon(`<path d="M12 3a9 9 0 0 0 0 18h1.5a1.5 1.5 0 0 0 0-3H12a2 2 0 0 1 0-4h4a5 5 0 0 0 0-10 9 9 0 0 0-4-1Z"/><circle cx="7.5" cy="10" r=".8" fill="currentColor"/><circle cx="9" cy="6.5" r=".8" fill="currentColor"/><circle cx="14" cy="6" r=".8" fill="currentColor"/><circle cx="17" cy="9" r=".8" fill="currentColor"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("JS Paint quick start", `<p>JS Paint recreates the classic desktop paint program in the browser. Pick a tool from the left, choose foreground and background colors, and draw on the canvas. Existing images can be opened from the File menu or dropped onto the window.</p>`,
+ fakeHelp("JS Paint saving artwork", `<p>Choose <strong>File → Save</strong> to download the current image. Save before clearing the canvas or closing the browser tab. Browser storage may restore a recent session, but downloaded files are the dependable copy.</p>`),
+ fakeHelp("JS Paint useful controls", `<p>Use <kbd>Ctrl</kbd>+<kbd>Z</kbd> to undo, the selection tools to move part of an image, and the magnifier for pixel-level edits. Right-clicking uses the background color for tools that support it.</p>`),
+ ),
+ },
+ URL: "https://jspaint.app/",
+ DisplayURL: "jspaint.app",
+ },
+ {
+ Id: "openstreetmap",
+ Name: "OpenStreetMap",
+ Icon: fakeIcon(`<path d="m3 6 6-3 6 3 6-3v15l-6 3-6-3-6 3Z"/><path d="M9 3v15M15 6v15"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("OpenStreetMap navigation", `<p>This embedded OpenStreetMap view is a public map that can be panned and zoomed without an account. Drag to move around the map, use the plus and minus controls to change scale, and follow the attribution link for project and data-source details.</p>`,
+ fakeHelp("OpenStreetMap map data", `<p>OpenStreetMap is maintained by a global contributor community. Roads, paths, buildings, and points of interest can vary in completeness. Confirm operationally important locations against an authoritative source.</p>`),
+ fakeHelp("OpenStreetMap opening the full map", `<p>The embedded view is intentionally compact. Use the OpenStreetMap attribution or view controls to open the full site when you need search, route planning, editing, or a shareable location link.</p>`),
+ ),
+ },
+ URL: "https://www.openstreetmap.org/export/embed.html?bbox=-0.15%2C51.49%2C-0.10%2C51.52&layer=mapnik",
+ DisplayURL: "openstreetmap.org",
+ },
+ {
+ Id: "wikipedia",
+ Name: "Wikipedia",
+ Icon: fakeIcon(`<path d="M4 5h16M6 5l5 14M18 5l-5 14M8 11h8"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("Wikipedia reading guide", `<p>Wikipedia provides community-written reference articles and links to supporting sources. Use the search field to find a topic, the contents menu to move through long articles, and citation links to inspect the original material.</p>`,
+ fakeHelp("Wikipedia evaluating articles", `<p>Check the references, revision history, and notices at the top of an article before relying on it. Treat Wikipedia as an orientation tool rather than the final authority for security, medical, legal, or operational decisions.</p>`),
+ fakeHelp("Wikipedia opening links", `<p>Normal article links remain inside the embedded workspace. Links to external sources may open a new tab or refuse to load in an iframe because each publisher controls its own embedding policy.</p>`),
+ ),
+ },
+ URL: "https://en.wikipedia.org/wiki/Main_Page",
+ DisplayURL: "wikipedia.org",
+ },
+ {
+ Id: "project-gutenberg",
+ Name: "Project Gutenberg",
+ Icon: fakeIcon(`<path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H11v18H6.5A2.5 2.5 0 0 0 4 22Z"/><path d="M20 4.5A2.5 2.5 0 0 0 17.5 2H13v18h4.5A2.5 2.5 0 0 1 20 22Z"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("Project Gutenberg quick start", `<p>Project Gutenberg offers free ebooks whose United States copyright has expired. Search by title or author, browse curated bookshelves, then choose a reading or download format from the book page.</p>`,
+ fakeHelp("Project Gutenberg choosing formats", `<p>Use the HTML version for reading in the browser, EPUB for most ebook readers, Kindle-compatible formats for supported devices, and plain text when portability matters more than typography.</p>`),
+ fakeHelp("Project Gutenberg copyright note", `<p>Copyright status differs by country. Project Gutenberg provides guidance for the United States, but users outside the United States should confirm that a work is in the public domain where they live before downloading or redistributing it.</p>`),
+ ),
+ },
+ URL: "https://www.gutenberg.org/",
+ DisplayURL: "gutenberg.org",
+ },
+ {
+ Id: "launcher-guide",
+ Name: "Launcher Guide",
+ Icon: fakeIcon(`<circle cx="12" cy="12" r="9"/><path d="m15 9-2 4-4 2 2-4Z"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("Launcher Guide getting started", `<p>The launcher keeps homelab applications in one browser window. Select an icon in the dock to open an application. Applications stay loaded while you switch between them, so unfinished work is normally preserved until the launcher tab is closed or refreshed.</p>`,
+ fakeHelp("Launcher Guide app help", `<p>Hover over an application icon and choose <strong>Help</strong> to read its local documentation. Documentation-only entries open their help immediately because they do not have an application URL.</p>`),
+ fakeHelp("Launcher Guide troubleshooting", `<p>If an application remains blank, confirm that the site is reachable directly and that your VPN is connected when the service is private. Some sites intentionally block iframe embedding; those services should provide an external-open fallback in a production catalog.</p>`),
+ ),
+ },
+ DisplayURL: "Documentation",
+ },
+ {
+ Id: "homelab-access",
+ Name: "Homelab Access",
+ Icon: fakeIcon(`<path d="M12 3a5 5 0 0 0-5 5v3"/><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M12 15v2"/><path d="M17 8a5 5 0 0 0-5-5"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("Homelab Access network requirements", `<p>Public test applications work from any internet connection. Private homelab applications may require the approved VPN or local network before their names resolve and their pages load.</p>`,
+ fakeHelp("Homelab Access connection checklist", `<ol><li>Confirm that the device has internet access.</li><li>Connect the homelab VPN and wait for it to report a healthy connection.</li><li>Reload the application once.</li><li>If it still fails, open the service URL directly to separate launcher problems from network problems.</li></ol>`),
+ fakeHelp("Homelab Access reporting a problem", `<p>Include the application name, approximate time, device and browser, VPN state, and the exact error shown. Never include passwords, session cookies, private keys, or access tokens in a support message.</p>`),
+ ),
+ },
+ DisplayURL: "Documentation",
+ },
+ {
+ Id: "privacy-guide",
+ Name: "Privacy Guide",
+ Icon: fakeIcon(`<path d="M12 3 20 6v5c0 5-3.4 8.4-8 10-4.6-1.6-8-5-8-10V6Z"/><path d="m9 12 2 2 4-4"/>`),
+ Help: []HelpDocumentRendered{
+ fakeHelp("Privacy Guide embedded applications", `<p>Each application runs in its own iframe and remains governed by that application's privacy policy, storage behavior, and security controls. The launcher does not make a public service private merely by displaying it inside the homelab interface.</p>`,
+ fakeHelp("Privacy Guide sensitive information", `<p>Use public test tools only with non-sensitive sample data. Production secrets, customer information, internal documents, and private keys should be handled only by approved self-hosted services.</p>`),
+ fakeHelp("Privacy Guide shared devices", `<p>Sign out of applications before leaving a shared device. Close the launcher, clear downloaded files, and remove site data when required by policy. Closing an iframe does not necessarily end the application's server-side session.</p>`),
+ ),
+ },
+ DisplayURL: "Documentation",
+ },
+ }}
}
diff --git a/core/installer/server/launcher/fake_app_directory_test.go b/core/installer/server/launcher/fake_app_directory_test.go
new file mode 100644
index 0000000..89a23d8
--- /dev/null
+++ b/core/installer/server/launcher/fake_app_directory_test.go
@@ -0,0 +1,86 @@
+package launcher
+
+import (
+ "net/url"
+ "regexp"
+ "testing"
+)
+
+func TestFakeAppDirectoryCatalog(t *testing.T) {
+ directory := NewFakeAppDirectory()
+ apps, err := directory.GetAllApps()
+ if err != nil {
+ t.Fatalf("GetAllApps() returned an error: %v", err)
+ }
+ if got, want := len(apps), 11; got != want {
+ t.Fatalf("GetAllApps() returned %d apps, want %d", got, want)
+ }
+
+ validID := regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
+ appIDs := make(map[string]struct{}, len(apps))
+ helpIDs := make(map[string]string)
+ launchable := 0
+ helpOnly := 0
+
+ for _, app := range apps {
+ if !validID.MatchString(app.Id) {
+ t.Errorf("app %q has invalid ID %q", app.Name, app.Id)
+ }
+ if _, exists := appIDs[app.Id]; exists {
+ t.Errorf("app ID %q is duplicated", app.Id)
+ }
+ appIDs[app.Id] = struct{}{}
+
+ if app.Name == "" {
+ t.Errorf("app %q has an empty name", app.Id)
+ }
+ if app.Icon == "" {
+ t.Errorf("app %q has an empty icon", app.Id)
+ }
+ if len(app.Help) == 0 {
+ t.Errorf("app %q has no help content", app.Id)
+ }
+ assertFakeHelp(t, app.Id, app.Help, helpIDs)
+
+ if app.URL == "" {
+ helpOnly++
+ continue
+ }
+ launchable++
+ u, err := url.Parse(app.URL)
+ if err != nil {
+ t.Errorf("app %q has an invalid URL %q: %v", app.Id, app.URL, err)
+ continue
+ }
+ if u.Scheme != "https" || u.Host == "" {
+ t.Errorf("app %q URL %q must be an absolute HTTPS URL", app.Id, app.URL)
+ }
+ }
+
+ if got, want := launchable, 8; got != want {
+ t.Errorf("catalog has %d launchable apps, want %d", got, want)
+ }
+ if got, want := helpOnly, 3; got != want {
+ t.Errorf("catalog has %d help-only apps, want %d", got, want)
+ }
+}
+
+func assertFakeHelp(t *testing.T, appID string, documents []HelpDocumentRendered, helpIDs map[string]string) {
+ t.Helper()
+ for _, document := range documents {
+ if document.Title == "" {
+ t.Errorf("app %q contains help with an empty title", appID)
+ }
+ if document.Contents == "" {
+ t.Errorf("app %q help %q has empty contents", appID, document.Title)
+ }
+
+ helpID := cleanAppName(document.Title)
+ if previousAppID, exists := helpIDs[helpID]; exists {
+ t.Errorf("help DOM ID %q is shared by apps %q and %q", helpID, previousAppID, appID)
+ } else {
+ helpIDs[helpID] = appID
+ }
+ assertFakeHelp(t, appID, document.Children, helpIDs)
+ }
+}
diff --git a/core/installer/server/launcher/server_test.go b/core/installer/server/launcher/server_test.go
new file mode 100644
index 0000000..0086823
--- /dev/null
+++ b/core/installer/server/launcher/server_test.go
@@ -0,0 +1,345 @@
+package launcher
+
+import (
+ "bytes"
+ "html/template"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func renderLauncherForTest(t *testing.T) string {
+ t.Helper()
+ apps := []AppLauncherInfo{
+ {
+ Id: "alpha-app",
+ Name: "Alpha App",
+ Icon: template.HTML(`<svg aria-hidden="true"></svg>`),
+ URL: "https://alpha.example/",
+ DisplayURL: "alpha.example",
+ Help: []HelpDocumentRendered{
+ {
+ Title: "Quick start",
+ Contents: template.HTML(`<p>Alpha help</p>`),
+ Children: []HelpDocumentRendered{{
+ Title: "Details",
+ Contents: template.HTML(`<p>Nested help</p>`),
+ Children: []HelpDocumentRendered{{
+ Title: "Advanced",
+ Contents: template.HTML(`<p>Advanced help</p>`),
+ Children: []HelpDocumentRendered{{
+ Title: "Deep",
+ Contents: template.HTML(`<p>Deep help</p>`),
+ Children: []HelpDocumentRendered{{Title: "Deeper", Contents: template.HTML(`<p>Deeper help</p>`)}},
+ }},
+ }},
+ }},
+ },
+ },
+ },
+ {
+ Id: "guide",
+ Name: "Guide",
+ Icon: template.HTML(`<svg aria-hidden="true"></svg>`),
+ Help: []HelpDocumentRendered{{
+ Title: "Quick start",
+ Contents: template.HTML(`<p>Guide help</p>`),
+ }},
+ },
+ }
+ server, err := NewLauncherServer(0, "https://auth.example", staticAppDirectory{apps: apps})
+ if err != nil {
+ t.Fatalf("NewLauncherServer() returned an error: %v", err)
+ }
+ var rendered bytes.Buffer
+ err = server.homeTmpl.Execute(&rendered, homeHandlerData{
+ LoggedInUsername: "gio",
+ AllAppsInfo: apps,
+ AuthBaseAddr: "https://auth.example",
+ })
+ if err != nil {
+ t.Fatalf("render launcher: %v", err)
+ }
+ return rendered.String()
+}
+
+func TestLauncherRenderingSemanticContracts(t *testing.T) {
+ rendered := renderLauncherForTest(t)
+ contracts := []string{
+ `/static/main.css?v=0.15.0`,
+ `/static/main.js?v=0.15.0`,
+ `<aside id="left-panel" aria-label="Application dock" data-collapsed="false">`,
+ `<div class="dock-content" id="dock-content">`,
+ `<button class="account-button" id="account-button" type="button"`,
+ `data-app-id="launcher-account"`,
+ `data-app-url="https://auth.example"`,
+ `data-app-name="Account"`,
+ `data-has-help="false"`,
+ `data-launcher-caption="account"`,
+ `aria-label="Open account details"`,
+ `aria-describedby="account-label"`,
+ `<div class="app-label" id="account-label" role="tooltip"><p>Account details</p></div>`,
+ `<nav class="app-list scrollbar-custom" aria-label="Applications">`,
+ `class="app-button" type="button"`,
+ `data-app-id="alpha-app"`,
+ `data-app-url="https://alpha.example/"`,
+ `data-app-name="Alpha App"`,
+ `data-has-help="true"`,
+ `data-launcher-caption="alpha-app"`,
+ `aria-label="Open Alpha App"`,
+ `data-app-id="guide"`,
+ `data-app-url=""`,
+ `aria-label="Open Help for Guide"`,
+ `aria-controls="help-sidebar" aria-expanded="false"`,
+ `<div class="app-label" id="app-label-alpha-app" role="tooltip">`,
+ `id="dock-help-button" type="button" data-launcher-caption="dock-help" aria-controls="help-sidebar" aria-expanded="false" aria-label="Open Help" aria-describedby="dock-help-label"`,
+ `<div class="app-label" id="dock-help-label" role="tooltip"><p>Help</p></div>`,
+ `id="dock-toggle-button" type="button" aria-controls="dock-content" aria-expanded="true" aria-label="Collapse application dock"`,
+ `<div class="workspace-pane" id="workspace-pane">`,
+ `<iframe id="appFrame-default" class="appFrame" title="Launcher welcome">`,
+ `class="workspace-status" id="workspace-status" aria-live="polite" aria-atomic="true" hidden`,
+ `<aside id="help-sidebar" aria-labelledby="help-sidebar-title" data-help-open="false" data-help-pinned="false" hidden>`,
+ `id="help-resizer" role="separator" aria-label="Resize Help sidebar" aria-orientation="vertical" aria-valuemin="320" aria-valuemax="960" aria-valuenow="480" tabindex="0"`,
+ `<h1 id="help-sidebar-title" tabindex="-1">Help</h1>`,
+ `class="icon-button help-pin-button" id="help-pin-button" type="button" aria-pressed="false" aria-label="Pin Help sidebar"`,
+ `<circle cx="16" cy="16" r="14" fill="#7f9f7f"`,
+ `<path fill="#3a3a3a"`,
+ `id="help-close-button" type="button" aria-label="Close Help"`,
+ `<details class="help-contents" id="help-contents">`,
+ `<nav class="scrollbar-custom" id="help-contents-nav" aria-label="Help contents">`,
+ `class="help-outline-app" data-help-app-id="alpha-app"`,
+ `href="#help-app-heading-alpha-app" data-help-app-id="alpha-app" data-help-heading-id="help-app-heading-alpha-app"`,
+ `href="#help-heading-alpha-app-topic-0-0" data-help-app-id="alpha-app" data-help-heading-id="help-heading-alpha-app-topic-0-0"`,
+ `<article class="help-document scrollbar-custom" id="help-document">`,
+ `class="help-app-section" id="help-app-alpha-app" data-help-app-id="alpha-app"`,
+ `<h2 id="help-app-heading-alpha-app" tabindex="-1">Alpha App</h2>`,
+ `<h3 id="help-heading-alpha-app-topic-0" tabindex="-1">Quick start</h3>`,
+ `<h4 id="help-heading-alpha-app-topic-0-0" tabindex="-1">Details</h4>`,
+ `<h5 id="help-heading-alpha-app-topic-0-0-0" tabindex="-1">Advanced</h5>`,
+ `<h6 id="help-heading-alpha-app-topic-0-0-0-0" tabindex="-1">Deep</h6>`,
+ `<h6 id="help-heading-alpha-app-topic-0-0-0-0-0" tabindex="-1">Deeper</h6>`,
+ `class="help-app-section" id="help-app-guide" data-help-app-id="guide"`,
+ `<h2 id="help-app-heading-guide" tabindex="-1">Guide</h2>`,
+ }
+ for _, contract := range contracts {
+ if !strings.Contains(rendered, contract) {
+ t.Errorf("rendered launcher is missing contract %q", contract)
+ }
+ }
+
+ appListIndex := strings.Index(rendered, `<nav class="app-list scrollbar-custom"`)
+ helpIndex := strings.Index(rendered, `id="dock-help-button"`)
+ accountIndex := strings.Index(rendered, `id="account-button"`)
+ if !(appListIndex >= 0 && appListIndex < helpIndex && helpIndex < accountIndex) {
+ t.Error("rendered dock must order applications, Help, then Account")
+ }
+
+ for _, obsolete := range []string{
+ "<dialog",
+ "help-topic-button",
+ "help-content-template",
+ "data-modal-id",
+ `role="tab"`,
+ `role="tree"`,
+ `help-active-context`,
+ `help-jump-button`,
+ `account-menu`,
+ `profile-button`,
+ } {
+ if strings.Contains(rendered, obsolete) {
+ t.Errorf("rendered launcher retains obsolete Help contract %q", obsolete)
+ }
+ }
+
+ idPattern := regexp.MustCompile(`\sid="([^"]+)"`)
+ seen := make(map[string]struct{})
+ for _, match := range idPattern.FindAllStringSubmatch(rendered, -1) {
+ if _, exists := seen[match[1]]; exists {
+ t.Errorf("rendered duplicate ID %q", match[1])
+ }
+ seen[match[1]] = struct{}{}
+ }
+}
+
+func TestLauncherStaticInteractionContracts(t *testing.T) {
+ contents, err := staticAssets.ReadFile("static/main.js")
+ if err != nil {
+ t.Fatalf("read embedded main.js: %v", err)
+ }
+ javascript := string(contents)
+ welcome := "Welcome to the dodo: application launcher, think of it as your desktop environment. You can launch applications from left-hand side dock. You should setup VPN clients on your devices, so you can install applications from Application Manager and access your private network. Instructions on how to do that can be viewed by clicking <b>Help</b> button after hovering over <b>Headscale</b> icon in the dock."
+ if !strings.Contains(javascript, welcome) {
+ t.Error("main.js does not preserve the exact welcome copy and bold emphasis")
+ }
+ for _, contract := range []string{
+ "new Map()",
+ `fetch("/", { cache: "no-store" })`,
+ "POLL_INTERVAL_MS = 5000",
+ `classList.toggle("is-active", active)`,
+ `DOCK_COLLAPSED_KEY = "launcher:dock-collapsed"`,
+ `dockContent.inert = collapsed`,
+ "SLOW_LOAD_MS = 12000",
+ `record.phase === "slow"`,
+ "is taking longer than expected",
+ "clearRecordTimer(record)",
+ `record.iframe.removeEventListener("load", record.loadHandler)`,
+ `const running = Boolean(launchable && !active && state.iframes.has(appId))`,
+ `button.classList.toggle("is-running", running)`,
+ `Open ${button.dataset.appName}, current app`,
+ `running ? ", running in background" : ""`,
+ `Open Help for ${button.dataset.appName}`,
+ `suppressVisibleAppLabel()`,
+ `appList.addEventListener("scroll", handleAppListScroll)`,
+ `button.classList.add("is-caption-visible")`,
+ `event.pointerType === "touch"`,
+ `helpTargetAppId: null`,
+ `workspacePane.inert = true`,
+ `workspacePane.setAttribute("aria-hidden", "true")`,
+ `workspaceStatus.setAttribute("aria-live", "off")`,
+ `dockHelpButton.setAttribute("aria-expanded", String(state.helpOpen))`,
+ `const targetAppId = defaultHelpTarget()`,
+ `return helpOwners()[0] || null`,
+ `helpSidebar.contains(target)`,
+ `!helpContents.contains(target)`,
+ `!state.helpPinned`,
+ `rightPanel.contains(target)`,
+ `!helpSidebar.contains(target)`,
+ `activateApp(accountTrigger)`,
+ `captionKey(button)`,
+ `findCaptionButton(state.captionAppId)`,
+ `dockContent.addEventListener("pointerover", handleAppPointerOver)`,
+ `const dockRect = dock.getBoundingClientRect()`,
+ `Math.max(dockRect.right + 8, 8)`,
+ `accountButton.dataset.appId === appId`,
+ `accountButton.classList.toggle("is-active", accountActive)`,
+ `closeHelp(false)`,
+ `reconcileHelp(snapshot)`,
+ `if (snapshot.willMutate)`,
+ `keyedOrderChanged(oldIds, incomingIds)`,
+ `appList.children[index] !== currentEntry`,
+ `document.activeElement.closest(".help-outline-app")`,
+ `focusedOutlineLink.dataset.helpHeadingId`,
+ `focusedOwnerLost`,
+ `focusHelpHeading(helpHeadingFor(state.helpTargetAppId))`,
+ `replacementLink.focus({ preventScroll: true })`,
+ `replacementHeading.focus({ preventScroll: true })`,
+ `HELP_WIDTH_KEY = "launcher:help-width:v1"`,
+ `HELP_DEFAULT_WIDTH = 480`,
+ `HELP_MIN_WIDTH = 320`,
+ `HELP_MAX_WIDTH = 960`,
+ `availableWidth * 0.75`,
+ `availableWidth - HELP_MIN_WORKSPACE_WIDTH`,
+ `Number.isFinite(value)`,
+ `state.helpPreferredWidth = effective`,
+ `setPointerCapture(event.pointerId)`,
+ `workspacePane.dataset.helpResizing = "true"`,
+ `event.shiftKey ? 64 : 16`,
+ `event.key === "ArrowLeft"`,
+ `event.key === "ArrowRight"`,
+ `event.key === "Home"`,
+ `event.key === "End"`,
+ `event.key === "Enter"`,
+ `setHelpPinned(!state.helpPinned)`,
+ `state.helpOpen && closeOpenHelp && !state.helpPinned`,
+ `hasHelp ? "Open Help" : "Help unavailable"`,
+ `new ResizeObserver(() => applyHelpWidth())`,
+ `state.helpControlFocus`,
+ `endHelpResize()`,
+ } {
+ if !strings.Contains(javascript, contract) {
+ t.Errorf("main.js is missing interaction contract %q", contract)
+ }
+ }
+ for _, obsolete := range []string{
+ "copyToClipboard",
+ "data-tooltip",
+ "data-placement",
+ "style.color",
+ "pulsate",
+ "fadeout",
+ "initDock",
+ "failed to load",
+ "could not load",
+ "touchstart",
+ "longpress",
+ "tooltipDelay",
+ "dialog.showModal",
+ "dialog.close",
+ "HTMLDialogElement",
+ "help-topic-button",
+ "selectHelpTopic",
+ "helpJumpButton",
+ "helpActiveContext",
+ "is-active-app-help",
+ "accountMenu",
+ "toggleAccount",
+ "closeAccount",
+ } {
+ if strings.Contains(javascript, obsolete) {
+ t.Errorf("main.js retains obsolete behavior %q", obsolete)
+ }
+ }
+
+ styles, err := staticAssets.ReadFile("static/main.css")
+ if err != nil {
+ t.Fatalf("read embedded main.css: %v", err)
+ }
+ css := string(styles)
+ for _, contract := range []string{
+ ".app-button.is-running",
+ ".app-button.is-caption-visible + .app-label",
+ "border-left: 2px solid var(--color-identity)",
+ "background-color: var(--color-action)",
+ "#help-sidebar",
+ ".help-document",
+ ".help-app-section",
+ ".help-resizer",
+ "touch-action: none",
+ `#right-panel[data-help-pinned="true"] #help-sidebar`,
+ `.help-contents:not([open]) nav`,
+ `position: absolute`,
+ `max-width: none`,
+ `.help-contents li::before`,
+ `touch-action: pan-y`,
+ `-webkit-overflow-scrolling: touch`,
+ `.app-label {`,
+ `.dock-help-button.is-caption-visible + .app-label`,
+ `.account-button.is-caption-visible + .app-label`,
+ `border-bottom: 2px solid var(--color-identity)`,
+ `#left-panel[data-collapsed="true"] .dock-toggle-button`,
+ `justify-content: flex-start`,
+ `width: 0`,
+ `height: 0`,
+ `flex-basis: 0`,
+ `background-color: transparent`,
+ `top: -44px`,
+ `align-items: flex-end`,
+ `border-right: 1px solid var(--color-ink)`,
+ `border-top: 1px solid var(--color-ink)`,
+ `.app-list::before`,
+ `flex: 1 0 0`,
+ `.account-button.is-active #user-initial`,
+ `.app-button:active`,
+ `.dock-help-button:active`,
+ `.account-button:active`,
+ `transform: scale(0.92)`,
+ } {
+ if !strings.Contains(css, contract) {
+ t.Errorf("main.css is missing launcher contract %q", contract)
+ }
+ }
+ for _, obsolete := range []string{
+ ".app-help-modal",
+ ".modal-article",
+ ".help-topic-button",
+ ".help-content[hidden]",
+ ".help-active-context",
+ ".help-contents a.is-active-app-help",
+ ".account-menu",
+ ".profile-button",
+ } {
+ if strings.Contains(css, obsolete) {
+ t.Errorf("main.css retains obsolete modal Help selector %q", obsolete)
+ }
+ }
+}
diff --git a/core/installer/server/launcher/static/base.css b/core/installer/server/launcher/static/base.css
new file mode 100644
index 0000000..f764d51
--- /dev/null
+++ b/core/installer/server/launcher/static/base.css
@@ -0,0 +1,79 @@
+:root {
+ --color-surface: #d6d6d6;
+ --color-ink: #3a3a3a;
+ --color-action: #7f9f7f;
+ --color-identity: #d4888d;
+
+ font-family: Hack, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ line-height: 1.5;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ margin: 0;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button,
+[role="button"] {
+ border: 0;
+ border-radius: 0;
+ color: inherit;
+ background: none;
+ cursor: pointer;
+}
+
+button:disabled,
+[role="button"][aria-disabled="true"] {
+ cursor: default;
+}
+
+a {
+ color: inherit;
+}
+
+button:focus-visible,
+a:focus-visible,
+[role="button"]:focus-visible {
+ outline: 2px solid var(--color-action);
+ outline-offset: 2px;
+}
+
+.icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+dialog:not([open]) {
+ display: none;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/core/installer/server/launcher/static/main.css b/core/installer/server/launcher/static/main.css
index c7993dd..e13df2a 100644
--- a/core/installer/server/launcher/static/main.css
+++ b/core/installer/server/launcher/static/main.css
@@ -1,375 +1,866 @@
-:root:not([data-theme]) {
- --pico-background-color: unset;
- --pico-color: unset;
-}
-
-:root {
- --bg: #d6d6d6;
- --bodyBg: #3a3a3a;
- --text: #3a3a3a;
- --formText: #d6d6d6;
- --button: #7f9f7f;
- --logo: #d4888d;
- --pico-font-size: 14px;
-}
-
-html {
+html,
+body {
+ width: 100%;
height: 100%;
- margin: 0;
- padding: 0;
}
body {
- margin: 0;
- padding: 0;
- font-family: Hack, monospace;
+ --dock-expanded-size: 64px;
display: flex;
- height: 100%;
- padding-left: 0 !important;
- padding-right: 0 !important;
- background-color: var(--bodyBg);
- overflow-x: hidden;
- overflow-y: hidden;
+ height: 100vh;
+ height: 100dvh;
+ overflow: hidden;
+ color: var(--color-ink);
+ background-color: var(--color-ink);
+ font-size: 14px;
}
#left-panel {
- width: 80px;
- background-color: var(--bg);
+ position: relative;
+ z-index: 2;
+ width: var(--dock-expanded-size);
+ min-width: 0;
+ min-height: 0;
+ flex: 0 0 var(--dock-expanded-size);
+ border-right: 1px solid var(--color-ink);
+ background-color: var(--color-surface);
+ transition: width 160ms ease, flex-basis 160ms ease;
+}
+
+#left-panel[data-collapsed="true"] {
+ width: 0;
+ flex-basis: 0;
+ border-right: 0;
+ background-color: transparent;
+}
+
+.dock-content {
display: flex;
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
flex-direction: column;
align-items: center;
+ background-color: var(--color-surface);
+}
+
+#left-panel[data-collapsed="true"] .dock-content {
+ display: none;
+}
+
+.dock-toggle-button {
+ position: absolute;
+ top: 50%;
+ right: -22px;
+ z-index: 4;
+ display: flex;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ transform: translateY(-50%);
+ align-items: center;
+ justify-content: center;
+ color: var(--color-surface);
+}
+
+.dock-toggle-tab {
+ display: flex;
+ width: 28px;
+ height: 32px;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--color-ink);
+ border-radius: 0 16px 16px 0;
+ background-color: var(--color-ink);
+ box-shadow: 0 2px 6px rgb(58 58 58 / 24%);
+}
+
+.dock-toggle-icon {
+ display: block;
+ font-size: 24px;
+ line-height: 1;
+ transition: transform 160ms ease;
+}
+
+#left-panel[data-collapsed="true"] .dock-toggle-icon {
+ transform: rotate(180deg);
+}
+
+@media (min-width: 48rem) {
+ #left-panel[data-collapsed="true"] .dock-toggle-button {
+ right: auto;
+ left: 0;
+ justify-content: flex-start;
+ }
}
.app-list {
display: flex;
+ width: 100%;
+ min-width: 0;
+ min-height: 0;
+ flex: 1;
flex-direction: column;
align-items: center;
- overflow-y: auto;
overflow-x: hidden;
- padding-top: 3px;
- width: 95% !important;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 4px 0;
+}
+
+.app-list::before,
+.app-list::after {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 0 0;
+ content: "";
}
.scrollbar-custom {
scrollbar-width: thin;
- scrollbar-color: var(--bodyBg) var(--bg);
+ scrollbar-color: var(--color-ink) var(--color-surface);
}
.scrollbar-custom::-webkit-scrollbar {
width: 6px;
+ height: 6px;
}
.scrollbar-custom::-webkit-scrollbar-track {
- background-color: var(--bg) !important;
+ background-color: var(--color-surface);
}
.scrollbar-custom::-webkit-scrollbar-thumb {
- background-color: var(--bodyBg) !important;
- border-radius: 4px !important;
-}
-
-.scrollbar-custom::-webkit-scrollbar-thumb:hover {
- background-color: var(--bodyBg);
+ border-radius: 3px;
+ background-color: var(--color-ink);
}
#right-panel {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ min-width: 0;
+ min-height: 0;
flex: 1;
- background-color: none !important;
- padding: 0 0 0 2px;
+ overflow: hidden;
+ background-color: var(--color-surface);
+}
+
+.workspace-pane {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.workspace-pane[data-help-resizing="true"] {
+ pointer-events: none;
+ user-select: none;
+}
+
+.workspace-pane[data-help-covered="true"] .workspace-status {
+ visibility: hidden;
}
.appFrame {
- border-radius: 0;
+ display: block;
width: 100%;
height: 100%;
border: 0;
+ border-radius: 0;
}
-.app-icon {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- width: 80px !important;
- height: 50px !important;
- margin-bottom: 10px !important;
- cursor: pointer !important;
- filter: grayscale(100%);
-}
-
-@keyframes pulsate {
- from {
- opacity: 1;
- }
- 10% {
- opacity: 0;
- }
- 20% {
- opacity: 1;
- }
- 30% {
- opacity: 0;
- }
- 40% {
- opacity: 1;
- }
- 50% {
- opacity: 0;
- }
- 60% {
- opacity: 1;
- }
- 70% {
- opacity: 0;
- }
- 80% {
- opacity: 1;
- }
- 90% {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-@keyframes fadeout {
- /* TODO(gio): figure out why animating from 1 does not work */
- from {
- opacity: 0.999;
- }
- to {
- opacity: 0;
- }
-}
-
-.pulsate {
- animation: pulsate 5s linear;
-}
-
-.fadeout {
- animation: fadeout 2s ease-in;
-}
-
-.tooltip {
- position: absolute;
- left: 80px;
- transform: translateY(-50%);
- background-color: var(--bodyBg);
- padding: 5px;
- z-index: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- visibility: hidden;
- opacity: 0;
- cursor: auto;
- font-size: 16px;
- box-shadow: 2px 2px 5px var(--bodyBg);
-}
-
-.help-button {
- margin-top: 5px !important;
- padding: 0 !important;
- border: 0 !important;
- margin-bottom: 1px !important;
- width: 100% !important;
- background-color: var(--button) !important;
- color: var(--bodyBg) !important;
- border-radius: 0 !important;
- cursor: pointer !important;
- font-size: 16px !important;
-}
-
-.tooltip p {
- color: var(--formText);
- margin: 0;
- cursor: auto;
-}
-
-.app-icon:hover {
- transform: scale(1.15);
-}
-
-.modal-left {
- overflow-y: auto;
- float: left;
- margin-left: 0px;
- padding-right: 10px;
- background-color: #fbfcfc;
- border-radius: 2px;
-}
-
-.modal-right {
- flex: 1;
- overflow-y: auto;
- float: right;
- color: var(--bg);
- padding-left: 10px;
- padding-right: 10px;
-}
-
-.app-help-modal {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- overflow: hidden;
-}
-
-.app-help-modal-article {
- display: flex;
- flex-direction: row;
- width: 100%;
- max-width: 100%;
- min-height: 97%;
- max-height: 97%;
- overflow: hidden;
-}
-
-.app-info-modal-article header {
- flex: 0 0 auto;
-}
-
-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- position: relative;
- margin-bottom: 2px !important;
- background-color: var(--bodyBg) !important;
-}
-
-header h4 {
- color: var(--formText) !important;
- padding-left: 10px;
-}
-
-.close-button {
- padding: 0;
- border: none;
- background: none;
- cursor: pointer;
- outline: none;
- width: 1.5em;
- height: 1.5em;
- position: absolute;
- top: 11px;
- right: 28px;
-}
-
-.modal-article {
- min-width: 80% !important;
- max-width: 80% !important;
- min-height: 90% !important;
- max-height: 90% !important;
- overflow: hidden;
- padding-left: 0px !important;
- padding-right: 0px !important;
-}
-
-.help-content {
+.appFrame[hidden] {
display: none;
}
-.circle {
- width: 50px;
- height: 50px;
- border-radius: 50%;
- background-color: var(--bodyBg);
+.workspace-status {
+ position: absolute;
+ top: 16px;
+ left: 50%;
+ z-index: 3;
display: flex;
- justify-content: center;
+ width: max-content;
+ max-width: calc(100% - 32px);
+ min-height: 44px;
+ padding: 10px 12px;
+ transform: translateX(-50%);
align-items: center;
- margin-top: 2px;
+ justify-content: center;
+ gap: 10px;
+ border: 1px solid var(--color-surface);
+ color: var(--color-surface);
+ background-color: var(--color-ink);
+ box-shadow: 0 2px 8px rgb(58 58 58 / 24%);
}
-#user-initial {
- font-size: 24px;
- text-align: center;
- line-height: 50px;
+.workspace-status[hidden],
+.workspace-status-actions[hidden],
+.loading-indicator[hidden] {
+ display: none;
+}
+
+.workspace-status p {
margin: 0;
- position: relative;
- display: inline-block;
- color: var(--logo);
+ color: var(--color-surface);
+ overflow-wrap: anywhere;
+ text-align: center;
}
-.user-circle {
- min-width: 80px !important;
- max-width: 80px !important;
- cursor: pointer;
+.loading-indicator {
+ width: 18px;
+ height: 18px;
+ flex: 0 0 18px;
+ border: 2px solid var(--color-surface);
+ border-top-color: var(--color-action);
+ border-radius: 50%;
+ animation: workspace-loading 800ms linear infinite;
+}
+
+.workspace-status-actions {
display: flex;
align-items: center;
+ gap: 8px;
+}
+
+.workspace-status-actions button,
+.workspace-status-actions a {
+ display: inline-flex;
+ min-height: 44px;
+ padding: 8px 10px;
+ align-items: center;
justify-content: center;
+ border: 1px solid var(--color-surface);
+ color: var(--color-surface);
+ background-color: var(--color-ink);
+ text-align: center;
+ text-decoration: none;
}
-.separator {
- margin-top: 2px !important;
- margin-bottom: 4px !important;
- border-width: 2px !important;
- border-color: var(--bodyBg) !important;
- width: 100% !important;
+@keyframes workspace-loading {
+ to {
+ transform: rotate(360deg);
+ }
}
-.modal-left ul {
- padding-inline-start: 0px !important;
- margin-bottom: 0px;
- list-style: none;
+.app-container {
+ position: relative;
+ width: 100%;
+ flex: 0 0 56px;
+}
+
+.app-button {
+ display: flex;
+ width: 52px;
+ height: 48px;
+ margin: 4px auto;
+ padding: 6px;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid transparent;
+ border-radius: 10px;
+ color: var(--color-ink);
+ transition: transform 120ms ease, color 120ms ease, background-color 120ms ease;
+}
+
+.app-button svg {
+ display: block;
+ width: 34px;
+ height: 34px;
+ max-width: 34px;
+ max-height: 34px;
+ color: inherit;
+}
+
+.app-button.is-running {
+ border-color: var(--color-ink);
+ color: var(--color-ink);
+ background-color: transparent;
+}
+
+.app-button.is-active {
+ border-color: var(--color-ink);
+ color: var(--color-action);
+ background-color: var(--color-ink);
+}
+
+.app-label {
+ border-left: 2px solid var(--color-identity);
+ color: var(--color-ink);
+ background-color: var(--color-surface);
font-size: 14px;
}
-.modal-left ul li {
- list-style: none !important;
- padding-inline-start: 10px !important;
- margin-bottom: 0px;
-}
-
-.modal-left ul li a {
- --pico-text-decoration: none;
- cursor: pointer;
-}
-.modal-left ul li a[aria-current] {
- color: var(--pico-primary);
-}
-
-.tooltip-user {
- position: absolute;
- top: 0px;
- left: 80px;
- width: 234px;
- background-color: var(--bodyBg);
- padding: 5px;
- z-index: 1;
+.app-label {
+ position: fixed;
+ top: 8px;
+ left: 72px;
+ z-index: 5;
display: flex;
+ max-width: min(280px, calc(100vw - 16px));
+ padding: 5px 8px;
flex-direction: column;
- align-items: center;
+ align-items: flex-start;
visibility: hidden;
- opacity: 0;
- cursor: auto;
- box-shadow: 2px 2px 5px var(--bodyBg);
+ pointer-events: none;
}
-.profile-button {
- margin-top: 5px !important;
- padding: 0 !important;
- border: 0 !important;
- margin-bottom: 5px !important;
- width: 100% !important;
- cursor: pointer !important;
- font-size: 19px !important;
- border-radius: 0;
- background-color: var(--button);
- color: var(--text) !important;
+.app-label::before {
+ position: absolute;
+ top: 50%;
+ left: -10px;
+ width: 8px;
+ height: 2px;
+ transform: translateY(-50%);
+ background-color: var(--color-action);
+ content: "";
}
-.tooltip-user p {
- color: white;
+.app-label p {
margin: 0;
- cursor: auto;
- font-size: 19px;
- color: var(--logo);
+ color: var(--color-ink);
+ overflow-wrap: anywhere;
+ text-align: left;
+}
+
+.app-button.is-caption-visible + .app-label,
+.dock-help-button.is-caption-visible + .app-label,
+.account-button.is-caption-visible + .app-label {
+ visibility: visible;
+}
+
+#help-sidebar {
+ --help-sidebar-width: 480px;
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 10;
+ display: flex;
+ width: var(--help-sidebar-width);
+ height: 100%;
+ min-width: 320px;
+ min-height: 0;
+ flex: 0 0 var(--help-sidebar-width);
+ flex-direction: column;
+ overflow: visible;
+ color: var(--color-ink);
+ background-color: var(--color-surface);
+ box-shadow: -4px 0 14px rgb(58 58 58 / 24%);
+}
+
+#right-panel[data-help-pinned="true"] #help-sidebar {
+ position: relative;
+ z-index: 1;
+ box-shadow: none;
+}
+
+#help-sidebar[hidden] {
+ display: none;
+}
+
+.help-resizer {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -10px;
+ z-index: 3;
+ width: 20px;
+ cursor: col-resize;
+ touch-action: none;
+}
+
+.help-resizer::after {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 9px;
+ width: 1px;
+ background-color: var(--color-ink);
+ content: "";
+}
+
+.help-resizer:focus-visible::after {
+ width: 2px;
+ background-color: var(--color-action);
+}
+
+.help-sidebar-header {
+ display: flex;
+ min-width: 0;
+ flex: 0 0 auto;
+ flex-direction: column;
+ border-bottom: 1px solid var(--color-ink);
+ color: var(--color-surface);
+ background-color: var(--color-ink);
+}
+
+.help-header-main {
+ display: flex;
+ min-width: 0;
+ min-height: 52px;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.help-sidebar-header h1 {
+ min-width: 0;
+ margin: 0;
+ padding: 8px 12px;
+ color: var(--color-surface);
+ font-size: 20px;
+}
+
+.help-header-actions {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+}
+
+.help-pin-button,
+.help-close-button {
+ width: 44px;
+ height: 44px;
+ flex: 0 0 44px;
+ padding: 8px;
+}
+
+.help-pin-button svg,
+.help-close-button svg {
+ width: 28px;
+ height: 28px;
+}
+
+.help-pin-button svg {
+ transition: transform 120ms ease;
+}
+
+.help-pin-button[aria-pressed="true"] svg {
+ transform: rotate(-45deg);
+}
+
+.help-close-button {
+ margin-right: 4px;
+}
+
+.help-contents {
+ position: relative;
+ z-index: 2;
+ height: 44px;
+ flex: 0 0 44px;
+ overflow: visible;
+ border-bottom: 1px solid var(--color-ink);
+ background-color: var(--color-surface);
+}
+
+.help-contents summary {
+ min-height: 44px;
+ padding: 10px 12px;
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.help-contents nav {
+ position: absolute;
+ top: 44px;
+ right: 0;
+ left: 0;
+ max-height: min(60vh, calc(100dvh - 140px));
+ padding: 4px 12px 10px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ touch-action: pan-y;
+ -webkit-overflow-scrolling: touch;
+ border-bottom: 1px solid var(--color-ink);
+ background-color: var(--color-surface);
+ box-shadow: 0 6px 12px rgb(58 58 58 / 24%);
+}
+
+.help-contents:not([open]) nav {
+ display: none;
+}
+
+.help-contents ul {
+ margin: 0;
+ padding-left: 14px;
+ list-style: none;
+}
+
+.help-contents > nav > ul {
+ padding-left: 0;
+}
+
+.help-contents li {
+ position: relative;
+ margin: 0;
+ padding-left: 14px;
+}
+
+.help-contents li::before {
+ position: absolute;
+ top: 12px;
+ left: 2px;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background-color: var(--color-ink);
+ content: "";
+}
+
+.help-contents a {
+ display: block;
+ min-height: 28px;
+ padding: 3px 2px;
+ color: var(--color-ink);
+ line-height: 22px;
+ overflow-wrap: anywhere;
+}
+
+.help-contents a[aria-current="location"] {
+ text-decoration-thickness: 2px;
+ text-decoration-color: var(--color-action);
+ font-weight: 700;
+}
+
+.help-document {
+ min-width: 0;
+ min-height: 0;
+ padding: 16px 20px 48px;
+ flex: 1;
+ overflow-x: hidden;
+ overflow-y: auto;
+ scroll-behavior: auto;
+ color: var(--color-ink);
+ background-color: var(--color-surface);
+}
+
+.help-app-section {
+ width: 100%;
+ max-width: none;
+ padding-bottom: 32px;
+ border-bottom: 1px solid var(--color-ink);
+}
+
+.help-app-section + .help-app-section {
+ padding-top: 24px;
+}
+
+.help-app-section:last-child {
+ border-bottom: 0;
+}
+
+.help-app-section h2,
+.help-topic-section h3,
+.help-topic-section h4,
+.help-topic-section h5,
+.help-topic-section h6 {
+ color: var(--color-ink);
+ scroll-margin-top: 12px;
+}
+
+.help-app-section h2 {
+ margin: 0 0 16px;
+ font-size: 22px;
+}
+
+.help-app-section h2:focus,
+.help-topic-section h3:focus,
+.help-topic-section h4:focus,
+.help-topic-section h5:focus,
+.help-topic-section h6:focus {
+ outline: 2px solid var(--color-action);
+ outline-offset: 4px;
+}
+
+.help-topic-section {
+ margin-left: 12px;
+}
+
+.help-topic-section h3,
+.help-topic-section h4,
+.help-topic-section h5,
+.help-topic-section h6 {
+ margin: 20px 0 8px;
+ font-size: 16px;
+}
+
+.help-topic-content {
+ color: var(--color-ink);
+ line-height: 1.5;
+}
+
+.help-topic-content p,
+.help-topic-content ol,
+.help-topic-content ul {
+ margin-top: 0;
+ margin-bottom: 1em;
+}
+
+.account {
+ position: relative;
+ display: flex;
+ width: 100%;
+ flex: 0 0 56px;
+ align-items: center;
+ justify-content: center;
+}
+
+.account-button {
+ display: flex;
+ width: 52px;
+ height: 52px;
+ padding: 4px;
+ align-items: center;
+ justify-content: center;
+ transition: transform 120ms ease;
+}
+
+.circle {
+ display: flex;
+ width: 44px;
+ height: 44px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background-color: var(--color-ink);
+}
+
+#user-initial {
+ color: var(--color-identity);
+ font-size: 20px;
+ transition: color 120ms ease;
+ line-height: 1;
+ text-align: center;
+}
+
+.account-button.is-active #user-initial {
+ color: var(--color-action);
+}
+
+.separator {
+ width: 100%;
+ height: 0;
+ margin: 2px 0;
+ border: 0;
+ border-top: 1px solid var(--color-ink);
+}
+
+.dock-help-button {
+ display: flex;
+ width: 100%;
+ min-height: 52px;
+ flex: 0 0 52px;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-ink);
+ font-size: 22px;
+ font-weight: 700;
+ transition: transform 120ms ease, color 120ms ease, background-color 120ms ease;
+}
+
+.dock-help-button:disabled {
+ opacity: 0.45;
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .app-button:hover {
+ transform: scale(1.08);
+ }
+
+}
+
+.app-button:active,
+.dock-help-button:active,
+.account-button:active {
+ transform: scale(0.92);
+}
+
+@media (max-width: 47.999rem) {
+ body {
+ flex-direction: column;
+ }
+
+ #left-panel {
+ order: 2;
+ width: 100%;
+ height: calc(var(--dock-expanded-size) + env(safe-area-inset-bottom));
+ flex: 0 0 calc(var(--dock-expanded-size) + env(safe-area-inset-bottom));
+ padding-bottom: env(safe-area-inset-bottom);
+ border-top: 1px solid var(--color-ink);
+ border-right: 0;
+ transition: height 160ms ease, flex-basis 160ms ease;
+ }
+
+ #left-panel[data-collapsed="true"] {
+ width: 100%;
+ height: 0;
+ flex-basis: 0;
+ padding-bottom: 0;
+ border-top: 0;
+ }
+
+ .dock-content {
+ width: 100%;
+ height: var(--dock-expanded-size);
+ flex-direction: row;
+ }
+
+ .dock-toggle-button {
+ top: -22px;
+ right: auto;
+ left: 50%;
+ transform: translateX(-50%);
+ }
+
+ .dock-toggle-tab {
+ width: 32px;
+ height: 28px;
+ border-radius: 16px 16px 0 0;
+ }
+
+ .dock-toggle-icon {
+ transform: rotate(-90deg);
+ }
+
+ #left-panel[data-collapsed="true"] .dock-toggle-button {
+ top: -44px;
+ align-items: flex-end;
+ }
+
+ #left-panel[data-collapsed="true"] .dock-toggle-icon {
+ transform: rotate(90deg);
+ }
+
+ #right-panel {
+ order: 1;
+ }
+
+ .account {
+ width: 56px;
+ flex: 0 0 56px;
+ }
+
+ .separator {
+ width: 0;
+ height: 100%;
+ margin: 0 2px;
+ border-top: 0;
+ border-left: 1px solid var(--color-ink);
+ }
+
+ .app-list {
+ width: auto;
+ height: 100%;
+ flex-direction: row;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 0 4px;
+ }
+
+ .app-container {
+ width: 56px;
+ height: 100%;
+ flex-basis: 56px;
+ }
+
+ .app-button {
+ margin: 6px 2px;
+ }
+
+ .app-label {
+ align-items: center;
+ border-left: 0;
+ border-bottom: 2px solid var(--color-identity);
+ }
+
+ .app-label::before {
+ top: auto;
+ bottom: -10px;
+ left: 50%;
+ width: 2px;
+ height: 8px;
+ transform: translateX(-50%);
+ }
+
+ .app-label p {
+ text-align: center;
+ }
+
+ .dock-help-button {
+ width: 52px;
+ height: 100%;
+ min-height: 44px;
+ flex: 0 0 52px;
+ border-top: 0;
+ border-left: 0;
+ }
+
+ .workspace-status {
+ top: 12px;
+ width: calc(100% - 24px);
+ max-width: 420px;
+ flex-wrap: wrap;
+ }
+
+ .workspace-status-actions {
+ width: 100%;
+ justify-content: center;
+ }
+
+ #help-sidebar,
+ #right-panel[data-help-pinned="true"] #help-sidebar {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ min-width: 0;
+ flex-basis: 100%;
+ box-shadow: none;
+ }
+
+ .help-resizer,
+ .help-pin-button {
+ display: none;
+ }
+
+ .help-header-main {
+ padding-top: env(safe-area-inset-top);
+ padding-right: env(safe-area-inset-right);
+ }
+
+ .help-contents nav {
+ height: calc(100dvh - var(--dock-expanded-size) - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 96px);
+ max-height: none;
+ overflow-y: scroll;
+ }
+
+ .help-document {
+ padding: 14px max(16px, env(safe-area-inset-right)) 40px max(16px, env(safe-area-inset-left));
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ #left-panel,
+ .dock-toggle-icon,
+ .app-button,
+ .dock-help-button,
+ .account-button,
+ #user-initial,
+ .help-pin-button svg {
+ transition-duration: 0s;
+ }
+
+ .app-button:hover,
+ .app-button:active,
+ .dock-help-button:active,
+ .account-button:active {
+ transform: none;
+ }
+
+ .loading-indicator {
+ animation: none;
+ }
}
diff --git a/core/installer/server/launcher/static/main.js b/core/installer/server/launcher/static/main.js
index ab34e4c..bf5741c 100644
--- a/core/installer/server/launcher/static/main.js
+++ b/core/installer/server/launcher/static/main.js
@@ -1,259 +1,1360 @@
-function showTooltip(obj) {
- obj.style.visibility = 'visible';
- obj.style.opacity = '1';
-}
-function hideTooltip(obj) {
- obj.style.visibility = 'hidden';
- obj.style.opacity = '0';
-}
+(() => {
+ "use strict";
-document.addEventListener("DOMContentLoaded", function () {
- document.getElementById('appFrame-default').contentDocument.write("Welcome to the dodo: application launcher, think of it as your desktop environment. You can launch applications from left-hand side dock. You should setup VPN clients on your devices, so you can install applications from Application Manager and access your private network. Instructions on how to do that can be viewed by clicking <b>Help</b> button after hovering over <b>Headscale</b> icon in the dock.");
- document.getElementById('appFrame-default').style.backgroundColor = '#d6d6d6';
- initDock();
- setTimeout(reloadDock, 5000);
-});
+ const WELCOME_HTML = "Welcome to the dodo: application launcher, think of it as your desktop environment. You can launch applications from left-hand side dock. You should setup VPN clients on your devices, so you can install applications from Application Manager and access your private network. Instructions on how to do that can be viewed by clicking <b>Help</b> button after hovering over <b>Headscale</b> icon in the dock.";
+ const POLL_INTERVAL_MS = 5000;
+ const SLOW_LOAD_MS = 12000;
+ const DOCK_COLLAPSED_KEY = "launcher:dock-collapsed";
+ const HELP_WIDTH_KEY = "launcher:help-width:v1";
+ const HELP_DEFAULT_WIDTH = 480;
+ const HELP_MIN_WIDTH = 320;
+ const HELP_MAX_WIDTH = 960;
+ const HELP_MIN_WORKSPACE_WIDTH = 360;
+ const DESKTOP_DOCK_QUERY = "(min-width: 48rem)";
-function copyToClipboard(elem, text) {
- navigator.clipboard.writeText(text);
- elem.setAttribute("data-tooltip", "Copied");
- elem.setAttribute("data-placement", "bottom");
- setTimeout(() => {
- elem.removeAttribute("data-tooltip");
- elem.removeAttribute("data-placement");
- }, 500);
-};
+ const state = {
+ activeAppId: null,
+ dockCollapsed: false,
+ iframes: new Map(),
+ signatures: new Map(),
+ helpSignatures: new Map(),
+ helpOpen: false,
+ helpPinned: false,
+ helpTargetAppId: null,
+ helpOpener: null,
+ helpPreferredWidth: HELP_DEFAULT_WIDTH,
+ helpEffectiveWidth: HELP_DEFAULT_WIDTH,
+ helpResize: null,
+ helpControlFocus: null,
+ captionAppId: null,
+ captionFocusAppId: null,
+ suppressedCaptionAppId: null,
+ keyboardInput: true,
+ polling: false,
+ };
-function reloadDock() {
- fetch("/")
- .then((resp) => {
- if (resp.ok) {
- return resp.text();
- } else {
- return undefined;
- }
- })
- .then((resp) => {
- if (!resp) {
- return;
- }
- const tmp = document.createElement("div");
- tmp.innerHTML = resp;
- const apps = document.querySelector(".app-list");
- let existing = [...document.querySelectorAll(".app-container")];
- let current = [...tmp.querySelectorAll(".app-container")];
- const getId = (e) => e.getAttribute("id");
- const existingIds = existing.map(getId);
- const currentIds = current.map(getId);
- existing.forEach((e) => {
- const id = getId(e);
- if (!currentIds.includes(id)) {
- e.classList.add("fadeout");
- setTimeout(() => apps.removeChild(e), 1900);
- }
- });
- let prevId = undefined;
- current.forEach((c) => {
- const id = getId(c);
- if (existingIds.includes(id)) {
- prevId = id;
- return;
- }
- c.classList.add("pulsate");
- if (prevId) {
- apps.insertBefore(c, document.getElementById(prevId).nextSibling);
- } else {
- apps.insertBefore(c, apps.firstChild);
- }
- prevId = id;
- });
- initDock();
- })
- .finally(() => setTimeout(reloadDock, 5000));
-}
+ let appList;
+ let rightPanel;
+ let workspacePane;
+ let welcomeFrame;
+ let accountButton;
+ let dock;
+ let dockContent;
+ let dockHelpButton;
+ let dockToggleButton;
+ let dockMediaQuery;
+ let captionMediaQuery;
+ let workspaceStatus;
+ let workspaceStatusMessage;
+ let workspaceStatusActions;
+ let loadingIndicator;
+ let workspaceOpenExternal;
+ let helpSidebar;
+ let helpContents;
+ let helpContentsNav;
+ let helpOutlineList;
+ let helpDocument;
+ let helpPinButton;
+ let helpCloseButton;
+ let helpResizer;
+ let helpResizeObserver;
-function initDock() {
- const icons = document.querySelectorAll(".app-icon");
- const circle = document.querySelector(".user-circle");
- const tooltipUser = document.querySelector("#tooltip-user");
- const initial = document.getElementById('user-initial');
+ function appEntries(root = document) {
+ return Array.from(root.querySelectorAll(".app-container"));
+ }
- circle.addEventListener('mouseenter', () => {
- icons.forEach(icon => {
- const tooltip = icon.nextElementSibling;
- hideTooltip(tooltip);
+ function entryId(entry) {
+ return entry.dataset.appId;
+ }
+
+ function appButton(entry) {
+ return entry.querySelector(".app-button");
+ }
+
+ function findAppButton(appId) {
+ if (accountButton && accountButton.dataset.appId === appId) {
+ return accountButton;
+ }
+ return Array.from(document.querySelectorAll(".app-button")).find(
+ (button) => button.dataset.appId === appId,
+ );
+ }
+
+ function captionKey(button) {
+ return button.dataset.launcherCaption || button.dataset.appId;
+ }
+
+ function findCaptionButton(key) {
+ return Array.from(document.querySelectorAll("[data-launcher-caption]")).find(
+ (button) => captionKey(button) === key,
+ );
+ }
+
+ function positionAppLabel(button) {
+ if (!button || !dockContent.contains(button)) {
+ return;
+ }
+ const label = button.nextElementSibling;
+ if (!label || !label.classList.contains("app-label")) {
+ return;
+ }
+ const buttonRect = button.getBoundingClientRect();
+ const labelWidth = label.offsetWidth;
+ const labelHeight = label.offsetHeight;
+ if (dockMediaQuery.matches) {
+ const top = Math.min(
+ Math.max(buttonRect.top + (buttonRect.height - labelHeight) / 2, 8),
+ Math.max(8, window.innerHeight - labelHeight - 8),
+ );
+ const dockRect = dock.getBoundingClientRect();
+ const left = Math.min(
+ Math.max(dockRect.right + 8, 8),
+ Math.max(8, window.innerWidth - labelWidth - 8),
+ );
+ label.style.left = `${left}px`;
+ label.style.top = `${top}px`;
+ return;
+ }
+ const left = Math.min(
+ Math.max(buttonRect.left + (buttonRect.width - labelWidth) / 2, 8),
+ Math.max(8, window.innerWidth - labelWidth - 8),
+ );
+ label.style.left = `${left}px`;
+ label.style.top = `${Math.max(8, buttonRect.top - labelHeight - 10)}px`;
+ }
+
+ function hideAppLabel(suppress = false) {
+ const button = state.captionAppId ? findCaptionButton(state.captionAppId) : null;
+ if (suppress && state.captionAppId) {
+ state.suppressedCaptionAppId = state.captionAppId;
+ }
+ if (button) {
+ button.classList.remove("is-caption-visible");
+ }
+ state.captionAppId = null;
+ state.captionFocusAppId = null;
+ }
+
+ function showAppLabel(button) {
+ const appId = captionKey(button);
+ if (state.suppressedCaptionAppId === appId) {
+ return;
+ }
+ if (state.suppressedCaptionAppId && state.suppressedCaptionAppId !== appId) {
+ state.suppressedCaptionAppId = null;
+ }
+ if (state.captionAppId !== appId) {
+ hideAppLabel();
+ }
+ positionAppLabel(button);
+ button.classList.add("is-caption-visible");
+ state.captionAppId = appId;
+ }
+
+ function resetCaptionSuppression(button) {
+ if (state.suppressedCaptionAppId === captionKey(button)) {
+ state.suppressedCaptionAppId = null;
+ }
+ }
+
+ function clearAppLabelState(appId) {
+ if (state.captionAppId === appId) {
+ hideAppLabel();
+ }
+ if (state.suppressedCaptionAppId === appId) {
+ state.suppressedCaptionAppId = null;
+ }
+ }
+
+ function handleAppPointerOver(event) {
+ const button = event.target.closest("[data-launcher-caption]");
+ if (
+ !button ||
+ event.pointerType === "touch" ||
+ !captionMediaQuery.matches ||
+ (event.relatedTarget && button.contains(event.relatedTarget))
+ ) {
+ return;
+ }
+ showAppLabel(button);
+ }
+
+ function handleAppPointerOut(event) {
+ const button = event.target.closest("[data-launcher-caption]");
+ if (!button || (event.relatedTarget && button.contains(event.relatedTarget))) {
+ return;
+ }
+ resetCaptionSuppression(button);
+ if (document.activeElement !== button && state.captionAppId === captionKey(button)) {
+ hideAppLabel();
+ }
+ }
+
+ function handleAppFocusIn(event) {
+ const button = event.target.closest("[data-launcher-caption]");
+ if (!button || (!state.keyboardInput && !button.matches(":focus-visible"))) {
+ return;
+ }
+ showAppLabel(button);
+ const key = captionKey(button);
+ state.captionFocusAppId = key;
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(() => {
+ if (state.captionFocusAppId === key) {
+ state.captionFocusAppId = null;
+ }
+ });
});
- showTooltip(tooltipUser);
- initial.style.color = "#7f9f7f";
- });
+ }
- circle.addEventListener('mouseleave', () => {
- hideTooltip(tooltipUser);
- initial.style.color = "#d4888d";
- });
+ function handleAppFocusOut(event) {
+ const button = event.target.closest("[data-launcher-caption]");
+ if (!button || (event.relatedTarget && button.contains(event.relatedTarget))) {
+ return;
+ }
+ resetCaptionSuppression(button);
+ if (
+ (!captionMediaQuery.matches || !button.matches(":hover")) &&
+ state.captionAppId === captionKey(button)
+ ) {
+ hideAppLabel();
+ }
+ }
- let hideTimeout;
- let activeTooltip;
+ function handleAppListScroll() {
+ const focusedButton = document.activeElement.closest(".app-button");
+ if (
+ focusedButton &&
+ state.captionAppId === captionKey(focusedButton) &&
+ state.captionFocusAppId === captionKey(focusedButton)
+ ) {
+ state.captionFocusAppId = null;
+ positionAppLabel(focusedButton);
+ return;
+ }
+ hideAppLabel(true);
+ }
- icons.forEach(function (icon) {
- if (activeAppId && icon.getAttribute("data-app-id") === activeAppId) {
- icon.style.color = "var(--button)";
- }
- icon.addEventListener("click", function (event) {
- event.stopPropagation();
- const appUrl = this.getAttribute("data-app-url");
- const appId = this.getAttribute("data-app-id");
- const modalId = this.getAttribute("data-modal-id");
+ function suppressVisibleAppLabel() {
+ hideAppLabel(true);
+ }
- if (!appUrl && modalId) {
- openModal(document.getElementById(modalId));
+ function readDockCollapsed() {
+ try {
+ return window.localStorage.getItem(DOCK_COLLAPSED_KEY) === "true";
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function persistDockCollapsed(collapsed) {
+ try {
+ window.localStorage.setItem(DOCK_COLLAPSED_KEY, String(collapsed));
+ } catch (_) {
+ // The dock remains usable when storage is unavailable.
+ }
+ }
+
+ function clamp(value, minimum, maximum) {
+ return Math.min(Math.max(value, minimum), maximum);
+ }
+
+ function readHelpPreferredWidth() {
+ try {
+ const stored = window.localStorage.getItem(HELP_WIDTH_KEY);
+ if (stored === null || stored.trim() === "") {
+ return HELP_DEFAULT_WIDTH;
+ }
+ const value = Number(stored);
+ if (!Number.isFinite(value)) {
+ return HELP_DEFAULT_WIDTH;
+ }
+ return clamp(value, HELP_MIN_WIDTH, HELP_MAX_WIDTH);
+ } catch (_) {
+ return HELP_DEFAULT_WIDTH;
+ }
+ }
+
+ function persistHelpPreferredWidth() {
+ try {
+ window.localStorage.setItem(HELP_WIDTH_KEY, String(state.helpPreferredWidth));
+ } catch (_) {
+ // Resizing remains available when storage is unavailable.
+ }
+ }
+
+ function helpAvailableWidth() {
+ return rightPanel.getBoundingClientRect().width;
+ }
+
+ function helpModeMaximum() {
+ const availableWidth = helpAvailableWidth();
+ const modeMaximum = state.helpPinned
+ ? Math.min(
+ HELP_MAX_WIDTH,
+ availableWidth * 0.75,
+ availableWidth - HELP_MIN_WORKSPACE_WIDTH,
+ )
+ : Math.min(HELP_MAX_WIDTH, availableWidth * 0.75);
+ return Math.max(HELP_MIN_WIDTH, modeMaximum);
+ }
+
+ function applyHelpWidth() {
+ const maximum = helpModeMaximum();
+ state.helpEffectiveWidth = clamp(
+ state.helpPreferredWidth,
+ HELP_MIN_WIDTH,
+ maximum,
+ );
+ helpSidebar.style.setProperty("--help-sidebar-width", `${state.helpEffectiveWidth}px`);
+ helpResizer.setAttribute("aria-valuemin", String(HELP_MIN_WIDTH));
+ helpResizer.setAttribute("aria-valuemax", String(Math.round(maximum)));
+ helpResizer.setAttribute("aria-valuenow", String(Math.round(state.helpEffectiveWidth)));
+ }
+
+ function setExplicitHelpWidth(width) {
+ const effective = clamp(width, HELP_MIN_WIDTH, helpModeMaximum());
+ state.helpPreferredWidth = effective;
+ applyHelpWidth();
+ persistHelpPreferredWidth();
+ }
+
+ function setDockCollapsed(collapsed, persist = true) {
+ endHelpResize();
+ state.dockCollapsed = collapsed;
+ dock.dataset.collapsed = String(collapsed);
+ dockContent.inert = collapsed;
+ dockContent.setAttribute("aria-hidden", String(collapsed));
+ dockToggleButton.setAttribute("aria-expanded", String(!collapsed));
+ dockToggleButton.setAttribute(
+ "aria-label",
+ collapsed ? "Expand application dock" : "Collapse application dock",
+ );
+ if (collapsed) {
+ const dockOwnedFocus = dockContent.contains(document.activeElement);
+ hideAppLabel();
+ if (dockOwnedFocus) {
+ dockToggleButton.focus();
+ }
+ }
+ if (persist) {
+ persistDockCollapsed(collapsed);
+ }
+ }
+
+ function handleDockBreakpointChange() {
+ hideAppLabel();
+ syncHelpResponsiveState();
+ }
+
+ function writeWelcome() {
+ const frameDocument = welcomeFrame.contentDocument;
+ frameDocument.open();
+ frameDocument.write(WELCOME_HTML);
+ frameDocument.close();
+ welcomeFrame.style.backgroundColor = "#d6d6d6";
+ }
+
+ function helpSectionFor(appId) {
+ return Array.from(helpDocument.querySelectorAll(".help-app-section")).find(
+ (section) => section.dataset.helpAppId === appId,
+ );
+ }
+
+ function helpOwners(root = helpDocument) {
+ return Array.from(root.querySelectorAll(".help-app-section")).map(
+ (section) => section.dataset.helpAppId,
+ );
+ }
+
+ function helpHeadingFor(appId) {
+ const section = helpSectionFor(appId);
+ return section ? section.querySelector("h2") : null;
+ }
+
+ function focusIsEligible(candidate) {
+ if (
+ !candidate ||
+ !candidate.isConnected ||
+ candidate.disabled ||
+ candidate.closest("[inert]")
+ ) {
+ return false;
+ }
+ const style = window.getComputedStyle(candidate);
+ return (
+ style.display !== "none" &&
+ style.visibility !== "hidden" &&
+ candidate.getClientRects().length > 0
+ );
+ }
+
+ function defaultHelpTarget() {
+ if (state.activeAppId && helpSectionFor(state.activeAppId)) {
+ return state.activeAppId;
+ }
+ if (state.helpTargetAppId && helpSectionFor(state.helpTargetAppId)) {
+ return state.helpTargetAppId;
+ }
+ return helpOwners()[0] || null;
+ }
+
+ function updateHelpTriggers() {
+ dockHelpButton.setAttribute("aria-expanded", String(state.helpOpen));
+ document.querySelectorAll('.app-button[aria-controls="help-sidebar"]').forEach((button) => {
+ button.setAttribute(
+ "aria-expanded",
+ String(state.helpOpen && button.dataset.appId === state.helpTargetAppId),
+ );
+ });
+ helpOutlineList.querySelectorAll(".help-app-link").forEach((link) => {
+ if (state.helpOpen && link.dataset.helpAppId === state.helpTargetAppId) {
+ link.setAttribute("aria-current", "location");
} else {
- if (!iframes[appId]) {
- createIframe(appId, appUrl);
- }
- activeAppId = appId;
- showIframe(appId);
- document.querySelectorAll(".app-icon").forEach((icon) => {
- icon.style.color = "var(--bodyBg)";
- });
- this.style.color = "var(--button)";
- };
+ link.removeAttribute("aria-current");
+ }
+ });
+ }
+
+ function projectHelpPinnedState() {
+ const pinned = state.helpOpen && state.helpPinned && dockMediaQuery.matches;
+ state.helpPinned = pinned;
+ helpSidebar.dataset.helpPinned = String(pinned);
+ rightPanel.dataset.helpPinned = String(pinned);
+ helpPinButton.setAttribute("aria-pressed", String(pinned));
+ helpPinButton.setAttribute("aria-label", pinned ? "Unpin Help sidebar" : "Pin Help sidebar");
+ updateHelpTriggers();
+ }
+
+ function focusHelpResponsiveFallback() {
+ const scrollTop = helpDocument.scrollTop;
+ const heading = helpHeadingFor(state.helpTargetAppId);
+ const candidate = heading || document.getElementById("help-sidebar-title") || helpCloseButton;
+ if (candidate) {
+ candidate.focus({ preventScroll: true });
+ helpDocument.scrollTop = scrollTop;
+ }
+ }
+
+ function setHelpPinned(pinned) {
+ if (!state.helpOpen || !dockMediaQuery.matches || state.helpPinned === pinned) {
+ return;
+ }
+ endHelpResize();
+ const documentScrollTop = helpDocument.scrollTop;
+ const outlineScrollTop = helpContentsNav.scrollTop;
+ if (!pinned && workspacePane.contains(document.activeElement)) {
+ focusHelpResponsiveFallback();
+ }
+ state.helpPinned = pinned;
+ projectHelpPinnedState();
+ setWorkspaceHelpOverlay(!pinned);
+ applyHelpWidth();
+ helpDocument.scrollTop = documentScrollTop;
+ helpContentsNav.scrollTop = outlineScrollTop;
+ }
+
+ function syncHelpResponsiveState() {
+ endHelpResize();
+ const desktop = dockMediaQuery.matches;
+ if (
+ !desktop &&
+ state.helpOpen &&
+ (state.helpControlFocus || workspacePane.contains(document.activeElement))
+ ) {
+ focusHelpResponsiveFallback();
+ }
+ helpPinButton.tabIndex = desktop ? 0 : -1;
+ helpResizer.tabIndex = desktop ? 0 : -1;
+ if (!desktop) {
+ state.helpPinned = false;
+ }
+ projectHelpPinnedState();
+ if (state.helpOpen) {
+ setWorkspaceHelpOverlay(!state.helpPinned);
+ }
+ applyHelpWidth();
+ }
+
+ function endHelpResize(pointerId = null) {
+ const resize = state.helpResize;
+ if (!resize || (pointerId !== null && resize.pointerId !== pointerId)) {
+ return false;
+ }
+ state.helpResize = null;
+ delete workspacePane.dataset.helpResizing;
+ if (helpResizer && helpResizer.hasPointerCapture(resize.pointerId)) {
+ helpResizer.releasePointerCapture(resize.pointerId);
+ }
+ return true;
+ }
+
+ function handleHelpResizePointerDown(event) {
+ if (!state.helpOpen || !dockMediaQuery.matches || event.button !== 0) {
+ return;
+ }
+ event.preventDefault();
+ endHelpResize();
+ state.helpResize = {
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startWidth: state.helpEffectiveWidth,
+ };
+ workspacePane.dataset.helpResizing = "true";
+ helpResizer.setPointerCapture(event.pointerId);
+ }
+
+ function handleHelpResizePointerMove(event) {
+ const resize = state.helpResize;
+ if (!resize || resize.pointerId !== event.pointerId) {
+ return;
+ }
+ event.preventDefault();
+ setExplicitHelpWidth(resize.startWidth + resize.startX - event.clientX);
+ }
+
+ function handleHelpResizePointerEnd(event) {
+ endHelpResize(event.pointerId);
+ }
+
+ function handleHelpResizeKeydown(event) {
+ if (!state.helpOpen || !dockMediaQuery.matches) {
+ return;
+ }
+ const step = event.shiftKey ? 64 : 16;
+ let width = null;
+ if (event.key === "ArrowLeft") {
+ width = state.helpEffectiveWidth + step;
+ } else if (event.key === "ArrowRight") {
+ width = state.helpEffectiveWidth - step;
+ } else if (event.key === "Home") {
+ width = HELP_MIN_WIDTH;
+ } else if (event.key === "End") {
+ width = helpModeMaximum();
+ } else if (event.key === "Enter") {
+ width = HELP_DEFAULT_WIDTH;
+ }
+ if (width === null) {
+ return;
+ }
+ event.preventDefault();
+ setExplicitHelpWidth(width);
+ }
+
+ function trackHelpControlFocus(event) {
+ if (event.target === helpPinButton) {
+ state.helpControlFocus = "pin";
+ } else if (event.target === helpResizer) {
+ state.helpControlFocus = "separator";
+ } else {
+ state.helpControlFocus = null;
+ }
+ }
+
+ function setWorkspaceHelpOverlay(open) {
+ if (open) {
+ workspacePane.inert = true;
+ workspacePane.setAttribute("aria-hidden", "true");
+ workspacePane.dataset.helpCovered = "true";
+ workspaceStatus.setAttribute("aria-live", "off");
+ return;
+ }
+ workspacePane.inert = false;
+ workspacePane.removeAttribute("aria-hidden");
+ delete workspacePane.dataset.helpCovered;
+ workspaceStatus.setAttribute("aria-live", "polite");
+ }
+
+ function focusHelpHeading(heading) {
+ if (!heading) {
+ return;
+ }
+ heading.focus({ preventScroll: true });
+ heading.scrollIntoView({ block: "start" });
+ }
+
+ function jumpToHelp(appId, headingId = "", opener = null) {
+ const appHeading = helpHeadingFor(appId);
+ if (!appHeading) {
+ return false;
+ }
+ const opening = !state.helpOpen;
+ if (opening) {
+ hideAppLabel();
+ state.helpOpener = opener;
+ state.helpOpen = true;
+ state.helpPinned = false;
+ helpSidebar.hidden = false;
+ helpSidebar.dataset.helpOpen = "true";
+ projectHelpPinnedState();
+ applyHelpWidth();
+ }
+ state.helpTargetAppId = appId;
+ updateHelpTriggers();
+ const requested = headingId ? document.getElementById(headingId) : null;
+ const heading = requested && helpSectionFor(appId).contains(requested) ? requested : appHeading;
+ focusHelpHeading(heading);
+ if (opening) {
+ setWorkspaceHelpOverlay(true);
+ }
+ if (!dockMediaQuery.matches) {
+ helpContents.open = false;
+ }
+ return true;
+ }
+
+ function helpFocusFallback() {
+ const ids = [state.helpTargetAppId, state.activeAppId].filter(Boolean);
+ for (const id of ids) {
+ const button = findAppButton(id);
+ if (focusIsEligible(button)) {
+ return button;
+ }
+ }
+ for (const candidate of [dockHelpButton, accountButton, dockToggleButton]) {
+ if (focusIsEligible(candidate)) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ function closeHelp(restoreFocus = true) {
+ if (!state.helpOpen) {
+ return;
+ }
+ const opener = state.helpOpener;
+ endHelpResize();
+ setWorkspaceHelpOverlay(false);
+ state.helpOpen = false;
+ state.helpPinned = false;
+ state.helpOpener = null;
+ helpSidebar.dataset.helpOpen = "false";
+ projectHelpPinnedState();
+ helpSidebar.hidden = true;
+ applyHelpWidth();
+ if (!restoreFocus) {
+ return;
+ }
+ const candidate = focusIsEligible(opener) ? opener : helpFocusFallback();
+ if (candidate) {
+ candidate.focus();
+ }
+ }
+
+ function updateDockHelp() {
+ const hasHelp = helpOwners().length > 0;
+ dockHelpButton.disabled = !hasHelp;
+ dockHelpButton.setAttribute("aria-label", hasHelp ? "Open Help" : "Help unavailable");
+ updateHelpTriggers();
+ }
+
+ function applyActiveState() {
+ document.querySelectorAll(".app-button").forEach((button) => {
+ const appId = button.dataset.appId;
+ const launchable = Boolean(button.dataset.appUrl);
+ const active = Boolean(launchable && state.activeAppId === appId);
+ const running = Boolean(launchable && !active && state.iframes.has(appId));
+ button.classList.toggle("is-active", active);
+ button.classList.toggle("is-running", running);
+ if (active) {
+ button.setAttribute("aria-current", "page");
+ button.setAttribute("aria-label", `Open ${button.dataset.appName}, current app`);
+ } else {
+ button.removeAttribute("aria-current");
+ button.setAttribute(
+ "aria-label",
+ launchable
+ ? `Open ${button.dataset.appName}${running ? ", running in background" : ""}`
+ : `Open Help for ${button.dataset.appName}`,
+ );
+ }
+ });
+ const accountActive = state.activeAppId === accountButton.dataset.appId;
+ accountButton.classList.toggle("is-active", accountActive);
+ if (accountActive) {
+ accountButton.setAttribute("aria-current", "page");
+ } else {
+ accountButton.removeAttribute("aria-current");
+ }
+ updateDockHelp();
+ }
+
+ function clearRecordTimer(record) {
+ if (record.timeoutID !== null) {
+ window.clearTimeout(record.timeoutID);
+ record.timeoutID = null;
+ }
+ }
+
+ function renderWorkspaceStatus() {
+ const record = state.activeAppId ? state.iframes.get(state.activeAppId) : null;
+ if (!record || record.loaded) {
+ workspaceStatus.hidden = true;
+ workspaceStatus.removeAttribute("data-phase");
+ workspaceStatusMessage.textContent = "";
+ workspaceStatusActions.hidden = true;
+ loadingIndicator.hidden = true;
+ workspaceOpenExternal.removeAttribute("href");
+ return;
+ }
+
+ workspaceStatus.hidden = false;
+ workspaceStatus.dataset.phase = record.phase;
+ workspaceStatusMessage.textContent =
+ record.phase === "slow"
+ ? `${record.name} is taking longer than expected`
+ : `Loading ${record.name}…`;
+ const slow = record.phase === "slow";
+ workspaceStatusActions.hidden = !slow;
+ loadingIndicator.hidden = slow;
+ if (slow) {
+ workspaceOpenExternal.href = record.url;
+ } else {
+ workspaceOpenExternal.removeAttribute("href");
+ }
+ }
+
+ function markIframeLoaded(record) {
+ if (state.iframes.get(record.appId) !== record) {
+ return;
+ }
+ clearRecordTimer(record);
+ record.loaded = true;
+ record.phase = "loaded";
+ if (state.activeAppId === record.appId) {
+ renderWorkspaceStatus();
+ }
+ }
+
+ function startIframeLoad(record) {
+ clearRecordTimer(record);
+ record.loaded = false;
+ record.phase = "loading";
+ record.iframe.src = record.url;
+ record.timeoutID = window.setTimeout(() => {
+ record.timeoutID = null;
+ if (state.iframes.get(record.appId) !== record || record.loaded) {
+ return;
+ }
+ record.phase = "slow";
+ if (state.activeAppId === record.appId) {
+ renderWorkspaceStatus();
+ }
+ }, SLOW_LOAD_MS);
+ if (state.activeAppId === record.appId) {
+ renderWorkspaceStatus();
+ }
+ }
+
+ function showWelcome() {
+ state.activeAppId = null;
+ welcomeFrame.hidden = false;
+ state.iframes.forEach((record) => {
+ record.iframe.hidden = true;
+ });
+ applyActiveState();
+ renderWorkspaceStatus();
+ }
+
+ function createIframe(button) {
+ const appId = button.dataset.appId;
+ const iframe = document.createElement("iframe");
+ iframe.id = `appFrame-${encodeURIComponent(appId)}`;
+ iframe.className = "appFrame";
+ iframe.title = `${button.dataset.appName} application`;
+ iframe.hidden = true;
+ iframe.dataset.appId = appId;
+ const record = {
+ appId,
+ iframe,
+ loaded: false,
+ phase: "loading",
+ timeoutID: null,
+ url: button.dataset.appUrl,
+ name: button.dataset.appName,
+ loadHandler: null,
+ };
+ record.loadHandler = () => markIframeLoaded(record);
+ iframe.addEventListener("load", record.loadHandler);
+ state.iframes.set(appId, record);
+ startIframeLoad(record);
+ workspacePane.appendChild(iframe);
+ return record;
+ }
+
+ function retireIframe(appId) {
+ const record = state.iframes.get(appId);
+ if (!record) {
+ return;
+ }
+ clearRecordTimer(record);
+ record.iframe.removeEventListener("load", record.loadHandler);
+ record.iframe.remove();
+ state.iframes.delete(appId);
+ if (state.activeAppId === appId) {
+ renderWorkspaceStatus();
+ }
+ }
+
+ function activateApp(button, closeOpenHelp = true) {
+ if (!button.dataset.appUrl) {
+ jumpToHelp(button.dataset.appId, "", button);
+ return;
+ }
+ if (state.helpOpen && closeOpenHelp && !state.helpPinned) {
+ closeHelp(false);
+ }
+ const appId = button.dataset.appId;
+ const record = state.iframes.get(appId) || createIframe(button);
+ state.activeAppId = appId;
+ welcomeFrame.hidden = true;
+ state.iframes.forEach((candidate, candidateId) => {
+ candidate.iframe.hidden = candidateId !== appId;
+ });
+ record.iframe.hidden = false;
+ applyActiveState();
+ renderWorkspaceStatus();
+ }
+
+ function retryActiveIframe() {
+ const record = state.activeAppId ? state.iframes.get(state.activeAppId) : null;
+ if (!record) {
+ return;
+ }
+ startIframeLoad(record);
+ }
+
+ function removalFocusId(removedId, oldIds, incomingIds) {
+ const oldIndex = oldIds.indexOf(removedId);
+ const survivors = new Set(incomingIds);
+ for (let index = oldIndex + 1; index < oldIds.length; index += 1) {
+ if (survivors.has(oldIds[index])) {
+ return oldIds[index];
+ }
+ }
+ for (let index = oldIndex - 1; index >= 0; index -= 1) {
+ if (survivors.has(oldIds[index])) {
+ return oldIds[index];
+ }
+ }
+ return null;
+ }
+
+ function focusAfterReconcile(request) {
+ if (!request) {
+ return;
+ }
+ const target = request.appId ? findAppButton(request.appId) : null;
+ if (target) {
+ target.focus();
+ return;
+ }
+ if (accountButton) {
+ accountButton.focus();
+ return;
+ }
+ dockHelpButton.focus();
+ }
+
+ function helpSignature(outline, section) {
+ return `${outline ? outline.outerHTML : ""}\n${section ? section.outerHTML : ""}`;
+ }
+
+ function firstEligibleDockControl() {
+ return (
+ Array.from(document.querySelectorAll(".app-button")).find(focusIsEligible) ||
+ (focusIsEligible(accountButton) ? accountButton : null) ||
+ (focusIsEligible(dockToggleButton) ? dockToggleButton : null)
+ );
+ }
+
+ function reconcileHelp(snapshot) {
+ const {
+ incomingSections,
+ incomingOutlineById,
+ incomingHelpIds: incomingIds,
+ incomingHelpSignatures,
+ oldOwners,
+ } = snapshot;
+ const incomingIdSet = new Set(incomingIds);
+ const currentSections = new Map(
+ Array.from(helpDocument.querySelectorAll(".help-app-section")).map((section) => [
+ section.dataset.helpAppId,
+ section,
+ ]),
+ );
+ const currentOutlines = new Map(
+ Array.from(helpOutlineList.querySelectorAll(".help-outline-app")).map((outline) => [
+ outline.dataset.helpAppId,
+ outline,
+ ]),
+ );
+ const scrollTop = helpDocument.scrollTop;
+ const focusedHeading = document.activeElement.closest(
+ ".help-app-section h2, .help-app-section h3, .help-app-section h4, .help-app-section h5, .help-app-section h6",
+ );
+ const focusedSection = document.activeElement.closest(".help-app-section");
+ const focusedOutline = document.activeElement.closest(".help-outline-app");
+ const focusedOutlineLink = document.activeElement.closest("a[data-help-heading-id]");
+ const focusedOwner = focusedSection
+ ? focusedSection.dataset.helpAppId
+ : focusedOutline && focusedOutline.dataset.helpAppId;
+ const focusedHeadingId = focusedHeading ? focusedHeading.id : "";
+ const focusedOutlineHeadingId = focusedOutlineLink
+ ? focusedOutlineLink.dataset.helpHeadingId
+ : "";
+ const outlineScrollTop = helpContentsNav.scrollTop;
+ let replacedFocusedOwner = false;
+
+ currentSections.forEach((section, id) => {
+ if (incomingIdSet.has(id)) {
+ return;
+ }
+ section.remove();
+ const outline = currentOutlines.get(id);
+ if (outline) {
+ outline.remove();
+ }
+ state.helpSignatures.delete(id);
+ currentSections.delete(id);
+ currentOutlines.delete(id);
});
- const tooltip = icon.nextElementSibling;
- [
- ['mouseenter', () => {
- clearTimeout(hideTimeout);
- if (activeTooltip && activeTooltip !== tooltip) {
- hideTooltip(activeTooltip);
- };
- const rect = icon.getBoundingClientRect();
- tooltip.style.top = `${rect.top + 26}px`;
- showTooltip(tooltip);
- activeTooltip = tooltip;
- }],
- ['mouseleave', () => {
- hideTimeout = setTimeout(() => {
- hideTooltip(tooltip);
- if (activeTooltip === tooltip) {
- activeTooltip = null;
- };
- }, 200);
- }],
- ].forEach(([event, listener]) => {
- icon.addEventListener(event, listener);
+ incomingSections.forEach((incomingSection, index) => {
+ const id = incomingSection.dataset.helpAppId;
+ const incomingOutline = incomingOutlineById.get(id);
+ const signature = incomingHelpSignatures.get(id);
+ let section = currentSections.get(id);
+ let outline = currentOutlines.get(id);
+ if (!section || !outline) {
+ section = document.importNode(incomingSection, true);
+ outline = document.importNode(incomingOutline, true);
+ currentSections.set(id, section);
+ currentOutlines.set(id, outline);
+ } else if (state.helpSignatures.get(id) !== signature) {
+ const replacementSection = document.importNode(incomingSection, true);
+ const replacementOutline = document.importNode(incomingOutline, true);
+ if (focusedOwner === id) {
+ replacedFocusedOwner = true;
+ }
+ section.replaceWith(replacementSection);
+ outline.replaceWith(replacementOutline);
+ section = replacementSection;
+ outline = replacementOutline;
+ currentSections.set(id, section);
+ currentOutlines.set(id, outline);
+ }
+ state.helpSignatures.set(id, signature);
+ if (helpOutlineList.children[index] !== outline) {
+ helpOutlineList.insertBefore(outline, helpOutlineList.children[index] || null);
+ }
+ if (helpDocument.children[index] !== section) {
+ helpDocument.insertBefore(section, helpDocument.children[index] || null);
+ }
});
- tooltip.addEventListener('mouseenter', () => {
- clearTimeout(hideTimeout);
- });
-
- tooltip.addEventListener('mouseleave', () => {
- hideTimeout = setTimeout(() => {
- hideTooltip(tooltip);
- if (activeTooltip === tooltip) {
- activeTooltip = null;
- };
- }, 200);
- });
- });
-
- let visibleModal = undefined;
- const openModal = function (modal) {
- modal.removeAttribute("close");
- modal.setAttribute("open", true);
- visibleModal = modal;
- };
-
- const closeModal = function (modal) {
- modal.removeAttribute("open");
- modal.setAttribute("close", true);
- visibleModal = undefined;
- };
-
- const helpButtons = document.querySelectorAll('.help-button');
-
- helpButtons.forEach(function (button) {
- button.addEventListener('click', function (event) {
- event.stopPropagation();
- const buttonId = button.getAttribute('id');
- const modalId = 'modal-' + buttonId.substring("help-button-".length);
- const closeHelpId = "close-help-" + buttonId.substring("help-button-".length);
- const modal = document.getElementById(modalId);
- openModal(modal);
- const closeHelpButton = document.getElementById(closeHelpId);
- closeHelpButton.addEventListener('click', function (event) {
- event.stopPropagation();
- closeModal(modal);
- });
- });
- });
-
- const modalHelpButtons = document.querySelectorAll('.title-menu');
-
- modalHelpButtons.forEach(function (button) {
- button.addEventListener('click', function (event) {
- event.stopPropagation();
- const helpTitle = button.getAttribute('id');
- const helpTitleId = helpTitle.substring('title-'.length);
- const helpContentId = 'help-content-' + helpTitleId;
- let clDiv = document.getElementById(helpContentId).parentNode;
- const allContentElements = clDiv.querySelectorAll('.help-content');
-
- allContentElements.forEach(function (contentElement) {
- contentElement.style.display = "none";
- });
-
- let currentHelpTitle = button;
- while (currentHelpTitle && !currentHelpTitle.classList.contains('modal-left')) {
- currentHelpTitle = currentHelpTitle.parentNode;
- if (currentHelpTitle === document.body) {
- currentHelpTitle = null;
- break;
+ const targetLost = Boolean(
+ state.helpTargetAppId && !incomingIdSet.has(state.helpTargetAppId),
+ );
+ const focusedOwnerLost = Boolean(focusedOwner && !incomingIdSet.has(focusedOwner));
+ if (targetLost) {
+ let fallback =
+ state.activeAppId && incomingIdSet.has(state.activeAppId) ? state.activeAppId : null;
+ const oldIndex = oldOwners.indexOf(state.helpTargetAppId);
+ if (!fallback) {
+ for (let index = oldIndex + 1; index < oldOwners.length; index += 1) {
+ if (incomingIdSet.has(oldOwners[index])) {
+ fallback = oldOwners[index];
+ break;
+ }
}
}
+ if (!fallback) {
+ for (let index = oldIndex - 1; index >= 0; index -= 1) {
+ if (incomingIdSet.has(oldOwners[index])) {
+ fallback = oldOwners[index];
+ break;
+ }
+ }
+ }
+ if (!fallback) {
+ const wasOpen = state.helpOpen;
+ closeHelp(false);
+ state.helpTargetAppId = null;
+ updateHelpTriggers();
+ if (wasOpen) {
+ const candidate = firstEligibleDockControl();
+ if (candidate) {
+ candidate.focus();
+ }
+ }
+ } else {
+ state.helpTargetAppId = fallback;
+ updateHelpTriggers();
+ if (state.helpOpen && focusedOwnerLost) {
+ focusHelpHeading(helpHeadingFor(fallback));
+ } else {
+ helpDocument.scrollTop = scrollTop;
+ }
+ }
+ } else if (
+ state.helpOpen &&
+ focusedOwnerLost &&
+ state.helpTargetAppId &&
+ incomingIdSet.has(state.helpTargetAppId)
+ ) {
+ focusHelpHeading(helpHeadingFor(state.helpTargetAppId));
+ } else if (replacedFocusedOwner) {
+ const replacementOutline = currentOutlines.get(focusedOwner);
+ const replacementLink =
+ focusedOutlineHeadingId && replacementOutline
+ ? Array.from(replacementOutline.querySelectorAll("a[data-help-heading-id]")).find(
+ (link) => link.dataset.helpHeadingId === focusedOutlineHeadingId,
+ )
+ : null;
+ const replacementHeading =
+ (focusedOutlineHeadingId && document.getElementById(focusedOutlineHeadingId)) ||
+ (focusedHeadingId && document.getElementById(focusedHeadingId)) ||
+ helpHeadingFor(focusedOwner);
+ if (replacementLink) {
+ replacementLink.focus({ preventScroll: true });
+ } else if (replacementHeading) {
+ replacementHeading.focus({ preventScroll: true });
+ }
+ helpContentsNav.scrollTop = outlineScrollTop;
+ helpDocument.scrollTop = scrollTop;
+ } else {
+ helpDocument.scrollTop = scrollTop;
+ }
+ updateDockHelp();
+ }
- currentHelpTitle.querySelectorAll('.title-menu').forEach(function (button) {
- button.removeAttribute("aria-current");
- });
+ function keyedOrderChanged(currentIds, incomingIds) {
+ return (
+ currentIds.length !== incomingIds.length ||
+ currentIds.some((id, index) => id !== incomingIds[index])
+ );
+ }
- document.getElementById(helpContentId).style.display = 'block';
- button.setAttribute("aria-current", "page");
+ function reconciliationSnapshot(parsedDocument) {
+ const incomingEntries = appEntries(parsedDocument);
+ const incomingIds = incomingEntries.map(entryId);
+ const incomingEntrySignatures = new Map(
+ incomingEntries.map((entry) => [entryId(entry), entry.outerHTML]),
+ );
+ const incomingSections = Array.from(parsedDocument.querySelectorAll(".help-app-section"));
+ const incomingOutlines = Array.from(parsedDocument.querySelectorAll(".help-outline-app"));
+ const incomingOutlineById = new Map(
+ incomingOutlines.map((outline) => [outline.dataset.helpAppId, outline]),
+ );
+ const incomingHelpIds = incomingSections.map((section) => section.dataset.helpAppId);
+ const incomingHelpSignatures = new Map(
+ incomingSections.map((section) => {
+ const id = section.dataset.helpAppId;
+ return [id, helpSignature(incomingOutlineById.get(id), section)];
+ }),
+ );
+ const oldEntries = appEntries();
+ const oldIds = oldEntries.map(entryId);
+ const oldOwners = helpOwners();
+ const dockChanged =
+ keyedOrderChanged(oldIds, incomingIds) ||
+ incomingIds.some((id) => state.signatures.get(id) !== incomingEntrySignatures.get(id));
+ const helpChanged =
+ keyedOrderChanged(oldOwners, incomingHelpIds) ||
+ incomingHelpIds.some(
+ (id) => state.helpSignatures.get(id) !== incomingHelpSignatures.get(id),
+ );
+ return {
+ incomingEntries,
+ incomingIds,
+ incomingEntrySignatures,
+ incomingSections,
+ incomingOutlineById,
+ incomingHelpIds,
+ incomingHelpSignatures,
+ oldEntries,
+ oldIds,
+ oldOwners,
+ willMutate: dockChanged || helpChanged,
+ };
+ }
+
+ function reconcileCatalog(parsedDocument) {
+ const snapshot = reconciliationSnapshot(parsedDocument);
+ if (snapshot.willMutate) {
+ endHelpResize();
+ }
+ const dockHelpHadFocus = document.activeElement === dockHelpButton;
+ const { incomingEntries, incomingIds, oldEntries, oldIds } = snapshot;
+ const incomingIdSet = new Set(incomingIds);
+ const oldById = new Map(oldEntries.map((entry) => [entryId(entry), entry]));
+ let focusRequest = null;
+
+ oldEntries.forEach((entry) => {
+ const id = entryId(entry);
+ if (incomingIdSet.has(id)) {
+ return;
+ }
+ const ownedFocus = entry.contains(document.activeElement);
+ const removedActiveApp = state.activeAppId === id;
+ if (ownedFocus || (dockHelpHadFocus && removedActiveApp)) {
+ focusRequest = { appId: removalFocusId(id, oldIds, incomingIds) };
+ }
+ clearAppLabelState(id);
+ retireIframe(id);
+ if (removedActiveApp) {
+ showWelcome();
+ }
+ state.signatures.delete(id);
+ entry.remove();
+ oldById.delete(id);
});
- });
- document.addEventListener("keydown", (event) => {
- if (event.key === "Escape" && visibleModal) {
- closeModal(visibleModal);
+ incomingEntries.forEach((incomingEntry, index) => {
+ const id = entryId(incomingEntry);
+ const incomingSignature = snapshot.incomingEntrySignatures.get(id);
+ let currentEntry = oldById.get(id);
+ if (!currentEntry) {
+ currentEntry = document.importNode(incomingEntry, true);
+ state.signatures.set(id, incomingSignature);
+ } else if (state.signatures.get(id) !== incomingSignature) {
+ const oldButton = appButton(currentEntry);
+ const incomingButton = appButton(incomingEntry);
+ const ownedFocus = currentEntry.contains(document.activeElement);
+ const oldURL = oldButton.dataset.appUrl;
+ const newURL = incomingButton.dataset.appUrl;
+ const oldName = oldButton.dataset.appName;
+ const newName = incomingButton.dataset.appName;
+ const losesActiveHelp =
+ dockHelpHadFocus &&
+ state.activeAppId === id &&
+ oldButton.dataset.hasHelp === "true" &&
+ incomingButton.dataset.hasHelp !== "true";
+ const replacement = document.importNode(incomingEntry, true);
+ clearAppLabelState(id);
+ currentEntry.replaceWith(replacement);
+ currentEntry = replacement;
+ state.signatures.set(id, incomingSignature);
+ oldById.set(id, replacement);
+
+ const record = state.iframes.get(id);
+ if (record && oldName !== newName && oldURL === newURL) {
+ record.name = newName;
+ record.iframe.title = `${newName} application`;
+ if (state.activeAppId === id) {
+ renderWorkspaceStatus();
+ }
+ }
+ if (oldURL !== newURL) {
+ retireIframe(id);
+ if (state.activeAppId === id) {
+ if (newURL) {
+ activateApp(appButton(replacement), false);
+ } else {
+ showWelcome();
+ }
+ }
+ }
+ if (ownedFocus || losesActiveHelp) {
+ focusRequest = { appId: id };
+ }
+ }
+ if (appList.children[index] !== currentEntry) {
+ appList.insertBefore(currentEntry, appList.children[index] || null);
+ }
+ });
+
+ if (state.activeAppId && !findAppButton(state.activeAppId)) {
+ showWelcome();
+ } else {
+ applyActiveState();
}
- });
+ focusAfterReconcile(focusRequest);
+ reconcileHelp(snapshot);
+ }
- document.addEventListener("click", (event) => {
- if (visibleModal === null || visibleModal === undefined) return;
- const modalContent = visibleModal.querySelector("article");
- const closeButton = visibleModal.querySelector(".close-button");
- if (!modalContent.contains(event.target) || closeButton.contains(event.target)) {
- closeModal(visibleModal);
+ async function pollCatalog() {
+ if (state.polling) {
+ return;
}
- });
-}
+ state.polling = true;
+ try {
+ const response = await fetch("/", { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error(`catalog request failed: ${response.status}`);
+ }
+ const html = await response.text();
+ const parsedDocument = new DOMParser().parseFromString(html, "text/html");
+ reconcileCatalog(parsedDocument);
+ } catch (_) {
+ // Keep the last good catalog and retry on the normal schedule.
+ } finally {
+ state.polling = false;
+ window.setTimeout(pollCatalog, POLL_INTERVAL_MS);
+ }
+ }
-let activeAppId = undefined;
-const iframes = {};
-const rightPanel = document.getElementById('right-panel');
+ function handleClick(event) {
+ const target = event.target;
+ if (
+ state.helpOpen &&
+ !state.helpPinned &&
+ rightPanel.contains(target) &&
+ !helpSidebar.contains(target)
+ ) {
+ closeHelp(true);
+ return;
+ }
+ if (
+ state.helpOpen &&
+ helpContents.open &&
+ helpSidebar.contains(target) &&
+ !helpContents.contains(target)
+ ) {
+ helpContents.open = false;
+ }
-function showIframe(appId) {
- document.querySelectorAll('.appFrame').forEach(iframe => {
- iframe.style.display = iframe.id === `appFrame-${appId}` ? 'block' : 'none';
- });
-};
+ const accountTrigger = target.closest("#account-button");
+ if (accountTrigger) {
+ activateApp(accountTrigger);
+ return;
+ }
-function createIframe(appId, appUrl) {
- const iframe = document.createElement('iframe');
- iframe.id = `appFrame-${appId}`;
- iframe.className = 'appFrame';
- iframe.src = appUrl;
- iframe.style.display = 'none';
- rightPanel.appendChild(iframe);
- iframes[appId] = iframe;
-};
+ const dockToggleTrigger = target.closest("#dock-toggle-button");
+ if (dockToggleTrigger) {
+ setDockCollapsed(!state.dockCollapsed);
+ return;
+ }
+
+ const appTrigger = target.closest(".app-button");
+ if (appTrigger) {
+ activateApp(appTrigger);
+ return;
+ }
+
+ const retryTrigger = target.closest("[data-retry-active-app]");
+ if (retryTrigger) {
+ retryActiveIframe();
+ return;
+ }
+
+ const helpTrigger = target.closest("#dock-help-button");
+ if (helpTrigger && !helpTrigger.disabled) {
+ const targetAppId = defaultHelpTarget();
+ if (targetAppId) {
+ jumpToHelp(targetAppId, "", helpTrigger);
+ }
+ return;
+ }
+
+ const pinTrigger = target.closest("#help-pin-button");
+ if (pinTrigger) {
+ setHelpPinned(!state.helpPinned);
+ return;
+ }
+
+ const contentsLink = target.closest("a[data-help-heading-id]");
+ if (contentsLink && helpSidebar.contains(contentsLink)) {
+ event.preventDefault();
+ jumpToHelp(
+ contentsLink.dataset.helpAppId,
+ contentsLink.dataset.helpHeadingId,
+ contentsLink,
+ );
+ return;
+ }
+
+ if (target.closest("#help-close-button")) {
+ closeHelp(true);
+ }
+ }
+
+ function handlePointerDown() {
+ state.keyboardInput = false;
+ }
+
+ function handleKeydown(event) {
+ state.keyboardInput = true;
+ if (event.key !== "Escape") {
+ return;
+ }
+ if (state.captionAppId) {
+ suppressVisibleAppLabel();
+ return;
+ }
+ if (state.helpResize) {
+ endHelpResize();
+ return;
+ }
+ if (state.helpOpen) {
+ closeHelp(true);
+ }
+ }
+
+ function initialize() {
+ appList = document.querySelector(".app-list");
+ rightPanel = document.getElementById("right-panel");
+ workspacePane = document.getElementById("workspace-pane");
+ welcomeFrame = document.getElementById("appFrame-default");
+ accountButton = document.getElementById("account-button");
+ dock = document.getElementById("left-panel");
+ dockContent = document.getElementById("dock-content");
+ dockHelpButton = document.getElementById("dock-help-button");
+ dockToggleButton = document.getElementById("dock-toggle-button");
+ dockMediaQuery = window.matchMedia(DESKTOP_DOCK_QUERY);
+ captionMediaQuery = window.matchMedia("(hover: hover) and (pointer: fine)");
+ workspaceStatus = document.getElementById("workspace-status");
+ workspaceStatusMessage = document.getElementById("workspace-status-message");
+ workspaceStatusActions = document.getElementById("workspace-status-actions");
+ loadingIndicator = document.getElementById("loading-indicator");
+ workspaceOpenExternal = document.getElementById("workspace-open-external");
+ helpSidebar = document.getElementById("help-sidebar");
+ helpContents = document.getElementById("help-contents");
+ helpContentsNav = document.getElementById("help-contents-nav");
+ helpOutlineList = document.getElementById("help-outline-list");
+ helpDocument = document.getElementById("help-document");
+ helpPinButton = document.getElementById("help-pin-button");
+ helpCloseButton = document.getElementById("help-close-button");
+ helpResizer = document.getElementById("help-resizer");
+
+ state.helpPreferredWidth = readHelpPreferredWidth();
+ setDockCollapsed(readDockCollapsed(), false);
+ syncHelpResponsiveState();
+ appEntries().forEach((entry) => {
+ state.signatures.set(entryId(entry), entry.outerHTML);
+ });
+ helpDocument.querySelectorAll(".help-app-section").forEach((section) => {
+ const id = section.dataset.helpAppId;
+ const outline = Array.from(helpOutlineList.querySelectorAll(".help-outline-app")).find(
+ (candidate) => candidate.dataset.helpAppId === id,
+ );
+ state.helpSignatures.set(id, helpSignature(outline, section));
+ });
+ writeWelcome();
+ applyActiveState();
+
+ document.addEventListener("click", handleClick);
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeydown);
+ document.addEventListener("focusin", trackHelpControlFocus);
+ dockContent.addEventListener("pointerover", handleAppPointerOver);
+ dockContent.addEventListener("pointerout", handleAppPointerOut);
+ dockContent.addEventListener("focusin", handleAppFocusIn);
+ dockContent.addEventListener("focusout", handleAppFocusOut);
+ appList.addEventListener("scroll", handleAppListScroll);
+ helpResizer.addEventListener("pointerdown", handleHelpResizePointerDown);
+ helpResizer.addEventListener("pointermove", handleHelpResizePointerMove);
+ helpResizer.addEventListener("pointerup", handleHelpResizePointerEnd);
+ helpResizer.addEventListener("pointercancel", handleHelpResizePointerEnd);
+ helpResizer.addEventListener("keydown", handleHelpResizeKeydown);
+ dockMediaQuery.addEventListener("change", handleDockBreakpointChange);
+ helpResizeObserver = new ResizeObserver(() => applyHelpWidth());
+ helpResizeObserver.observe(rightPanel);
+ window.addEventListener("blur", () => endHelpResize());
+ window.setTimeout(pollCatalog, POLL_INTERVAL_MS);
+ }
+
+ document.addEventListener("DOMContentLoaded", initialize, { once: true });
+})();
diff --git a/core/installer/server/launcher/templates/launcher.html b/core/installer/server/launcher/templates/launcher.html
index 41015e6..8b7bdbf 100644
--- a/core/installer/server/launcher/templates/launcher.html
+++ b/core/installer/server/launcher/templates/launcher.html
@@ -1,88 +1,156 @@
<!DOCTYPE html>
-<html lang="en" data-theme="light">
+<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dodo: Launcher</title>
- <link rel="stylesheet" type="text/css" href="/static/pico.2.0.6.min.css">
- <link rel="stylesheet" type="text/css" href="/static/main.css?v=0.0.22">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css">
+ <link rel="stylesheet" type="text/css" href="/static/base.css?v=0.1.0">
+ <link rel="stylesheet" type="text/css" href="/static/main.css?v=0.15.0">
</head>
-<body class="container-fluid">
- <div id="left-panel">
- <div class="user-circle">
- <div class="circle">
- <p id="user-initial">{{ GetUserInitials .LoggedInUsername }}</p>
- <div class="tooltip-user" id="tooltip-user">
- <p>{{ .LoggedInUsername }}</p>
- <a href="{{ .AuthBaseAddr }}/change-password" role="button" class="profile-button" target="_blank">change password</a>
- <a href="{{ .AuthBaseAddr }}/logout" role="button" class="profile-button">log out</a>
+<body>
+ <aside id="left-panel" aria-label="Application dock" data-collapsed="false">
+ <div class="dock-content" id="dock-content">
+ <nav class="app-list scrollbar-custom" aria-label="Applications">
+ {{ range .AllAppsInfo }}
+ <div class="app-container" data-app-id="{{ .Id }}">
+ <button class="app-button" type="button"
+ data-app-id="{{ .Id }}"
+ data-app-url="{{ .URL }}"
+ data-app-name="{{ .Name }}"
+ data-has-help="{{ if .Help }}true{{ else }}false{{ end }}"
+ data-launcher-caption="{{ .Id }}"
+ aria-label="{{ if .URL }}Open {{ .Name }}{{ else }}Open Help for {{ .Name }}{{ end }}"
+ aria-describedby="app-label-{{ CleanAppName .Id }}"
+ {{ if and (not .URL) .Help }}aria-controls="help-sidebar" aria-expanded="false"{{ end }}>
+ {{ .Icon }}
+ </button>
+ <div class="app-label" id="app-label-{{ CleanAppName .Id }}" role="tooltip">
+ <p>{{ .Name }}</p>
+ {{ if .DisplayURL }}<p>{{ .DisplayURL }}</p>{{ end }}
</div>
</div>
- </div>
- <hr class="separator">
- <div class="app-list scrollbar-custom">
- {{range .AllAppsInfo}}
- <div class="app-container" id="{{ .Id }}">
- <div class="app-icon" data-app-id="{{ .Id }}" data-app-url="{{ .URL }}" {{ if not .URL }}data-modal-id="modal-{{ CleanAppName .Id }}"{{ end }}>
- {{.Icon}}
- </div>
- <div class="tooltip">
- <p>{{ .Name }}</p>
- {{ if .DisplayURL }}
- <p>{{ .DisplayURL }}</p>
- {{ end }}
- {{ if .Help }}
- <button class="help-button" id="help-button-{{ CleanAppName .Id }}">Help</button>
- {{ end }}
- </div>
- <dialog class="app-help-modal" id="modal-{{ CleanAppName .Id }}" close>
- <article class="modal-article">
- <header>
- <h4>{{ .Name }}</h4>
- <button class="close-button" id="close-help-{{ CleanAppName .Id }}">
- <svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 32 32"><path fill="#d4888d" d="M16 2C8.2 2 2 8.2 2 16s6.2 14 14 14s14-6.2 14-14S23.8 2 16 2m5.4 21L16 17.6L10.6 23L9 21.4l5.4-5.4L9 10.6L10.6 9l5.4 5.4L21.4 9l1.6 1.6l-5.4 5.4l5.4 5.4z"/></svg>
- </button>
- </header>
- <div class="app-help-modal-article">
- <div class="modal-left">
- {{ template "help-menu-template" (dict "Help" .Help "First" true) }}
- </div>
- <div class="modal-right" id="modal-right-help-content-{{ CleanAppName .Id }}">
- <aside>
- <nav>
- {{ template "help-content-template" (dict "Help" .Help "First" true) }}
- </nav>
- </aside>
- </div>
- </div>
- </article>
- </dialog>
- </div>
- {{end}}
- </div>
- </div>
- <div id="right-panel">
- <iframe id="appFrame-default" class="appFrame" style="display: block;"></iframe>
- </div>
- {{ define "help-menu-template" }}
- {{ $first := .First }}
- <ul>
- {{ range $i, $h := .Help }}
- <li>
- <a class="title-menu secondary" id="title-{{ CleanAppName $h.Title }}" {{ if (and $first (eq 0 $i)) }}aria-current="page"{{ end }}>{{ $h.Title }}</a>
- {{ template "help-menu-template" (dict "Help" $h.Children "First" false) }}
- </li>
{{ end }}
- </ul>
+ </nav>
+ <hr class="separator">
+ <button class="dock-help-button" id="dock-help-button" type="button" data-launcher-caption="dock-help" aria-controls="help-sidebar" aria-expanded="false" aria-label="Open Help" aria-describedby="dock-help-label">
+ <span aria-hidden="true">?</span>
+ <span class="visually-hidden">Help</span>
+ </button>
+ <div class="app-label" id="dock-help-label" role="tooltip"><p>Help</p></div>
+ <div class="account">
+ <button class="account-button" id="account-button" type="button"
+ data-app-id="launcher-account"
+ data-app-url="{{ .AuthBaseAddr }}"
+ data-app-name="Account"
+ data-has-help="false"
+ data-launcher-caption="account"
+ aria-label="Open account details"
+ aria-describedby="account-label">
+ <span class="circle" aria-hidden="true">
+ <span id="user-initial">{{ GetUserInitials .LoggedInUsername }}</span>
+ </span>
+ </button>
+ <div class="app-label" id="account-label" role="tooltip"><p>Account details</p></div>
+ </div>
+ </div>
+ <button class="dock-toggle-button" id="dock-toggle-button" type="button" aria-controls="dock-content" aria-expanded="true" aria-label="Collapse application dock">
+ <span class="dock-toggle-tab" aria-hidden="true">
+ <span class="dock-toggle-icon">‹</span>
+ </span>
+ </button>
+ </aside>
+ <main id="right-panel" aria-label="Application workspace">
+ <div class="workspace-pane" id="workspace-pane">
+ <iframe id="appFrame-default" class="appFrame" title="Launcher welcome"></iframe>
+ <section class="workspace-status" id="workspace-status" aria-live="polite" aria-atomic="true" hidden>
+ <span class="loading-indicator" id="loading-indicator" aria-hidden="true"></span>
+ <p id="workspace-status-message"></p>
+ <div class="workspace-status-actions" id="workspace-status-actions" hidden>
+ <button id="workspace-retry-button" type="button" data-retry-active-app>Retry</button>
+ <a id="workspace-open-external" target="_blank" rel="noopener">Open in new tab</a>
+ </div>
+ </section>
+ </div>
+ <aside id="help-sidebar" aria-labelledby="help-sidebar-title" data-help-open="false" data-help-pinned="false" hidden>
+ <div class="help-resizer" id="help-resizer" role="separator" aria-label="Resize Help sidebar" aria-orientation="vertical" aria-valuemin="320" aria-valuemax="960" aria-valuenow="480" tabindex="0"></div>
+ <header class="help-sidebar-header">
+ <div class="help-header-main">
+ <h1 id="help-sidebar-title" tabindex="-1">Help</h1>
+ <div class="help-header-actions">
+ <button class="icon-button help-pin-button" id="help-pin-button" type="button" aria-pressed="false" aria-label="Pin Help sidebar">
+ <svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="14" fill="#7f9f7f"/><path fill="#3a3a3a" d="m21.4 7.2l3.4 3.4l-3.2 1.1l-3.8 3.8l.9 4l-1.4 1.4l-3-3l-5.2 5.2l-1.2-1.2l5.2-5.2l-3-3l1.4-1.4l4 .9l3.8-3.8z"/></svg>
+ </button>
+ <button class="icon-button help-close-button" id="help-close-button" type="button" aria-label="Close Help">
+ <svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 32 32" aria-hidden="true"><path fill="#d4888d" d="M16 2C8.2 2 2 8.2 2 16s6.2 14 14 14s14-6.2 14-14S23.8 2 16 2m5.4 21L16 17.6L10.6 23L9 21.4l5.4-5.4L9 10.6L10.6 9l5.4 5.4L21.4 9l1.6 1.6l-5.4 5.4l5.4 5.4z"/></svg>
+ </button>
+ </div>
+ </div>
+ </header>
+ <details class="help-contents" id="help-contents">
+ <summary>Contents</summary>
+ <nav class="scrollbar-custom" id="help-contents-nav" aria-label="Help contents">
+ <ul id="help-outline-list">
+ {{ range .AllAppsInfo }}{{ if .Help }}
+ <li class="help-outline-app" data-help-app-id="{{ .Id }}">
+ <a class="help-app-link" href="#help-app-heading-{{ CleanAppName .Id }}" data-help-app-id="{{ .Id }}" data-help-heading-id="help-app-heading-{{ CleanAppName .Id }}">{{ .Name }}</a>
+ {{ template "help-outline-topics" (dict "Help" .Help "AppID" .Id "CleanID" (CleanAppName .Id) "Path" "topic") }}
+ </li>
+ {{ end }}{{ end }}
+ </ul>
+ </nav>
+ </details>
+ <article class="help-document scrollbar-custom" id="help-document">
+ {{ range .AllAppsInfo }}{{ if .Help }}
+ <section class="help-app-section" id="help-app-{{ CleanAppName .Id }}" data-help-app-id="{{ .Id }}">
+ <h2 id="help-app-heading-{{ CleanAppName .Id }}" tabindex="-1">{{ .Name }}</h2>
+ {{ template "help-sections-h3" (dict "Help" .Help "AppID" .Id "CleanID" (CleanAppName .Id) "Path" "topic") }}
+ </section>
+ {{ end }}{{ end }}
+ </article>
+ </aside>
+ </main>
+
+ {{ define "help-outline-topics" }}
+ {{ if .Help }}<ul>{{ range $i, $h := .Help }}{{ $path := printf "%s-%d" $.Path $i }}
+ <li>
+ <a href="#help-heading-{{ $.CleanID }}-{{ $path }}" data-help-app-id="{{ $.AppID }}" data-help-heading-id="help-heading-{{ $.CleanID }}-{{ $path }}">{{ $h.Title }}</a>
+ {{ template "help-outline-topics" (dict "Help" $h.Children "AppID" $.AppID "CleanID" $.CleanID "Path" $path) }}
+ </li>
+ {{ end }}</ul>{{ end }}
{{ end }}
- {{ define "help-content-template" }}
- {{ $first := .First }}
- {{ range $i, $h := .Help }}
- <div class="help-content" id="help-content-{{ CleanAppName $h.Title }}" {{ if (and $first (eq 0 $i)) }}style="display: block;"{{ end }}> {{ $h.Contents }}</div>
- {{ template "help-content-template" (dict "Help" $h.Children "First" false) }}
- {{ end }}
+ {{ define "help-sections-h3" }}
+ {{ range $i, $h := .Help }}{{ $path := printf "%s-%d" $.Path $i }}
+ <section class="help-topic-section" id="help-section-{{ $.CleanID }}-{{ $path }}">
+ <h3 id="help-heading-{{ $.CleanID }}-{{ $path }}" tabindex="-1">{{ $h.Title }}</h3>
+ <div class="help-topic-content">{{ $h.Contents }}</div>
+ {{ template "help-sections-h4" (dict "Help" $h.Children "AppID" $.AppID "CleanID" $.CleanID "Path" $path) }}
+ </section>{{ end }}
{{ end }}
- <script src="/static/main.js?v=0.0.19"></script>
+ {{ define "help-sections-h4" }}
+ {{ range $i, $h := .Help }}{{ $path := printf "%s-%d" $.Path $i }}
+ <section class="help-topic-section" id="help-section-{{ $.CleanID }}-{{ $path }}">
+ <h4 id="help-heading-{{ $.CleanID }}-{{ $path }}" tabindex="-1">{{ $h.Title }}</h4>
+ <div class="help-topic-content">{{ $h.Contents }}</div>
+ {{ template "help-sections-h5" (dict "Help" $h.Children "AppID" $.AppID "CleanID" $.CleanID "Path" $path) }}
+ </section>{{ end }}
+ {{ end }}
+ {{ define "help-sections-h5" }}
+ {{ range $i, $h := .Help }}{{ $path := printf "%s-%d" $.Path $i }}
+ <section class="help-topic-section" id="help-section-{{ $.CleanID }}-{{ $path }}">
+ <h5 id="help-heading-{{ $.CleanID }}-{{ $path }}" tabindex="-1">{{ $h.Title }}</h5>
+ <div class="help-topic-content">{{ $h.Contents }}</div>
+ {{ template "help-sections-h6" (dict "Help" $h.Children "AppID" $.AppID "CleanID" $.CleanID "Path" $path) }}
+ </section>{{ end }}
+ {{ end }}
+ {{ define "help-sections-h6" }}
+ {{ range $i, $h := .Help }}{{ $path := printf "%s-%d" $.Path $i }}
+ <section class="help-topic-section" id="help-section-{{ $.CleanID }}-{{ $path }}">
+ <h6 id="help-heading-{{ $.CleanID }}-{{ $path }}" tabindex="-1">{{ $h.Title }}</h6>
+ <div class="help-topic-content">{{ $h.Contents }}</div>
+ {{ template "help-sections-h6" (dict "Help" $h.Children "AppID" $.AppID "CleanID" $.CleanID "Path" $path) }}
+ </section>{{ end }}
+ {{ end }}
+ <script src="/static/main.js?v=0.15.0"></script>
</body>
</html>