boot-page.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. /**
  2. * Framework-free boot page and failure report. It remains available when a
  3. * client plugin fails because React arrives only with the UI renderer.
  4. * @module @deepseek-ai/dsh-client-web/src/boot-page
  5. */
  6. import type { LoaderEntryState } from './loader-status.ts'
  7. import css from './boot-page.module.css'
  8. /** Create a div with one module class and optional text. */
  9. function div(className: string | undefined, text?: string): HTMLDivElement {
  10. const el = document.createElement('div')
  11. el.className = className ?? ''
  12. if (text !== undefined) el.textContent = text
  13. return el
  14. }
  15. /** Kernel-owned page mounted below the application's root element. */
  16. export class BootPage {
  17. private readonly root: HTMLDivElement
  18. private readonly card: HTMLDivElement
  19. private readonly wordmark: HTMLDivElement
  20. private readonly spinner: HTMLDivElement
  21. private readonly hint: HTMLDivElement
  22. private readonly states = new Map<string, LoaderEntryState>()
  23. private readonly active = new Set<string>()
  24. private total = 0
  25. private failure: string | undefined
  26. /**
  27. * Build and attach the boot page.
  28. * @param container - Application mount point.
  29. */
  30. constructor(container: HTMLElement) {
  31. this.root = div(css.boot)
  32. this.root.dataset.dshBoot = ''
  33. this.card = div(css.card)
  34. this.wordmark = div(css.wordmark, 'HARNESS')
  35. this.spinner = div(css.spinner)
  36. this.spinner.dataset.dshBootSpinner = ''
  37. this.hint = div(css.hint, 'Loading plugins…')
  38. this.card.append(this.wordmark, this.spinner, this.hint)
  39. this.root.append(this.card)
  40. container.append(this.root)
  41. this.updateProgress()
  42. }
  43. /**
  44. * Set the number of loader entries represented by the progress arc.
  45. * @param total - Complete boot roster size.
  46. */
  47. setTotal(total: number): void {
  48. this.total = total
  49. this.updateProgress()
  50. }
  51. /**
  52. * Project one loader entry's fiber state.
  53. * @param id - Loader entry name.
  54. * @param state - Projected fiber state.
  55. */
  56. setState(id: string, state: LoaderEntryState): void {
  57. this.states.set(id, state)
  58. if (state === 'active') this.active.add(id)
  59. this.updateProgress()
  60. this.render()
  61. }
  62. /**
  63. * Display the boot failure report.
  64. * @param message - Failure report text.
  65. */
  66. fail(message: string): void {
  67. this.failure = message
  68. this.render()
  69. }
  70. /** Detach the page before or after the UI renderer takes the mount point. */
  71. dispose(): void {
  72. this.root.remove()
  73. }
  74. /** Redraw the state-dependent content below the wordmark. */
  75. private render(): void {
  76. const failed = [...this.states].filter(([, state]) => state === 'failed').map(([id]) => id)
  77. if (this.failure === undefined && failed.length === 0) {
  78. if (this.spinner.parentElement !== this.card) {
  79. this.card.replaceChildren(this.wordmark, this.spinner, this.hint)
  80. }
  81. return
  82. }
  83. const report = div(css.failed)
  84. report.append(div(css.failedTitle, 'Failed to load plugins'))
  85. for (const id of failed) report.append(div(css.failedItem, id))
  86. if (this.failure !== undefined) report.append(div(css.failedItem, this.failure))
  87. this.card.replaceChildren(this.wordmark, report)
  88. }
  89. /** Grow the rotating arc monotonically as loader entries activate. */
  90. private updateProgress(): void {
  91. const ratio = this.total === 0 ? 0 : Math.min(this.active.size / this.total, 1)
  92. this.spinner.style.setProperty('--dsh-boot-arc', `${String(Math.round(72 + ratio * 216))}deg`)
  93. }
  94. }