@slopus/happy-terminal 0.3.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-CODEX +203 -0
  3. package/LICENSE-GROK-BUILD +204 -0
  4. package/LICENSE-HAPPY +21 -0
  5. package/README.md +60 -0
  6. package/THIRD-PARTY-NOTICES.md +34 -0
  7. package/dist/HappyTerminalUserError.d.ts +12 -0
  8. package/dist/app/AppTranscriptEntry.d.ts +33 -0
  9. package/dist/app/AppendOnlyStreamingRender.d.ts +12 -0
  10. package/dist/app/CodexFileDiff.d.ts +20 -0
  11. package/dist/app/CodexMcpToolCall.d.ts +25 -0
  12. package/dist/app/CodingAssistantAgentBackend.d.ts +81 -0
  13. package/dist/app/CodingAssistantApp.d.ts +128 -0
  14. package/dist/app/CompletedTurn.d.ts +11 -0
  15. package/dist/app/FileMentionAutocomplete.d.ts +30 -0
  16. package/dist/app/HappyTerminalProcessTerminal.d.ts +7 -0
  17. package/dist/app/NoticeChild.d.ts +4 -0
  18. package/dist/app/SecretMenuController.d.ts +31 -0
  19. package/dist/app/SelectionList.d.ts +24 -0
  20. package/dist/app/SessionDraftSync.d.ts +51 -0
  21. package/dist/app/StartupStatusApp.d.ts +34 -0
  22. package/dist/app/StartupStatusCardModel.d.ts +22 -0
  23. package/dist/app/TemporaryFullscreenController.d.ts +9 -0
  24. package/dist/app/TerminalColorLevel.d.ts +1 -0
  25. package/dist/app/TerminalOutputTrace.d.ts +12 -0
  26. package/dist/app/TerminalTheme.d.ts +11 -0
  27. package/dist/app/TranscriptEntryRenderCache.d.ts +10 -0
  28. package/dist/app/applyWorkflowRunUpdate.d.ts +2 -0
  29. package/dist/app/blendComposerBackground.d.ts +2 -0
  30. package/dist/app/boundedJsonStringify.d.ts +1 -0
  31. package/dist/app/calculateCacheHitPercent.d.ts +2 -0
  32. package/dist/app/codexDiffAnsi.d.ts +17 -0
  33. package/dist/app/compactCompletedTurnEntries.d.ts +2 -0
  34. package/dist/app/containsMarkdownTable.d.ts +1 -0
  35. package/dist/app/createAgentMarkdownTheme.d.ts +3 -0
  36. package/dist/app/createBackgroundTerminalViewer.d.ts +16 -0
  37. package/dist/app/createCompletedTurn.d.ts +6 -0
  38. package/dist/app/createEditorTheme.d.ts +3 -0
  39. package/dist/app/createFileMentionAutocompleteItems.d.ts +3 -0
  40. package/dist/app/createSecretInputPanel.d.ts +12 -0
  41. package/dist/app/createSelectionPanel.d.ts +14 -0
  42. package/dist/app/createSerialTaskQueue.d.ts +1 -0
  43. package/dist/app/createSessionPicker.d.ts +17 -0
  44. package/dist/app/createSlashCommands.d.ts +7 -0
  45. package/dist/app/createStopOnceHandler.d.ts +1 -0
  46. package/dist/app/createSubagentMonitor.d.ts +17 -0
  47. package/dist/app/createTerminalInputBurstHandler.d.ts +5 -0
  48. package/dist/app/createWorkflowMonitor.d.ts +15 -0
  49. package/dist/app/defaultTerminalTheme.d.ts +1 -0
  50. package/dist/app/describeModelChoice.d.ts +4 -0
  51. package/dist/app/describeProviderSignIn.d.ts +2 -0
  52. package/dist/app/describeReasoningLevel.d.ts +4 -0
  53. package/dist/app/detectCodexDiffLanguage.d.ts +1 -0
  54. package/dist/app/encodeModelChoice.d.ts +1 -0
  55. package/dist/app/endsAfterOpeningCodeFence.d.ts +1 -0
  56. package/dist/app/findFileMentionContext.d.ts +9 -0
  57. package/dist/app/findFileMentionPrefix.d.ts +5 -0
  58. package/dist/app/formatActivityElapsedTime.d.ts +1 -0
  59. package/dist/app/formatCodexMcpToolResult.d.ts +3 -0
  60. package/dist/app/formatCompactTokens.d.ts +1 -0
  61. package/dist/app/formatFileMention.d.ts +1 -0
  62. package/dist/app/formatProviderError.d.ts +6 -0
  63. package/dist/app/formatRelativeTime.d.ts +2 -0
  64. package/dist/app/formatResetDuration.d.ts +1 -0
  65. package/dist/app/formatSessionPickerEntry.d.ts +14 -0
  66. package/dist/app/formatSessionTokenStatus.d.ts +6 -0
  67. package/dist/app/formatSessionUsageSummary.d.ts +3 -0
  68. package/dist/app/formatStartupStatusUsageRemaining.d.ts +2 -0
  69. package/dist/app/formatStartupStatusUsageRows.d.ts +2 -0
  70. package/dist/app/formatSubagentToolCall.d.ts +10 -0
  71. package/dist/app/formatToolPermissionNotice.d.ts +3 -0
  72. package/dist/app/formatToolResultForDisplay.d.ts +2 -0
  73. package/dist/app/formatTurnUsageSummary.d.ts +2 -0
  74. package/dist/app/formatWorkUsageSummary.d.ts +14 -0
  75. package/dist/app/highlightAgentCode.d.ts +1 -0
  76. package/dist/app/highlightCodexDiffLine.d.ts +2 -0
  77. package/dist/app/highlightShellCommand.d.ts +1 -0
  78. package/dist/app/humanizeGoalStatus.d.ts +2 -0
  79. package/dist/app/humanizeMcpName.d.ts +1 -0
  80. package/dist/app/humanizePermissionMode.d.ts +2 -0
  81. package/dist/app/humanizePermissionReviewLevel.d.ts +2 -0
  82. package/dist/app/humanizeProviderId.d.ts +1 -0
  83. package/dist/app/humanizeReasoningLevel.d.ts +1 -0
  84. package/dist/app/humanizeSubagentStatus.d.ts +2 -0
  85. package/dist/app/humanizeToolName.d.ts +1 -0
  86. package/dist/app/humanizeWorkflowStatus.d.ts +2 -0
  87. package/dist/app/installResumeInstructions.d.ts +26 -0
  88. package/dist/app/installTerminalCrashCleanup.d.ts +26 -0
  89. package/dist/app/isLightTerminalBackground.d.ts +2 -0
  90. package/dist/app/layoutCodexFileDiff.d.ts +13 -0
  91. package/dist/app/looksLikeAuthenticationFailure.d.ts +5 -0
  92. package/dist/app/nearestXtermColorIndex.d.ts +2 -0
  93. package/dist/app/parseCodexMcpToolInvocation.d.ts +2 -0
  94. package/dist/app/parseSessionCommand.d.ts +6 -0
  95. package/dist/app/providerErrorResetAt.d.ts +2 -0
  96. package/dist/app/providerQuotaToStartupStatusUsage.d.ts +3 -0
  97. package/dist/app/readClipboardImage.d.ts +9 -0
  98. package/dist/app/renderActivityWave.d.ts +3 -0
  99. package/dist/app/renderAgentMarkdown.d.ts +8 -0
  100. package/dist/app/renderBackgroundTerminalCompletion.d.ts +1 -0
  101. package/dist/app/renderBackgroundTerminalInteraction.d.ts +2 -0
  102. package/dist/app/renderBackgroundTerminalSummary.d.ts +1 -0
  103. package/dist/app/renderChildRows.d.ts +13 -0
  104. package/dist/app/renderCodexDiffLine.d.ts +3 -0
  105. package/dist/app/renderCodexFileDiff.d.ts +7 -0
  106. package/dist/app/renderCodexMcpToolCall.d.ts +2 -0
  107. package/dist/app/renderCompletedTurnStats.d.ts +2 -0
  108. package/dist/app/renderExecCommand.d.ts +11 -0
  109. package/dist/app/renderExploration.d.ts +9 -0
  110. package/dist/app/renderFullscreenComponent.d.ts +8 -0
  111. package/dist/app/renderHappyTerminalBanner.d.ts +6 -0
  112. package/dist/app/renderHappyTerminalVersion.d.ts +1 -0
  113. package/dist/app/renderNoticeWithChildren.d.ts +7 -0
  114. package/dist/app/renderPendingSteeringMessages.d.ts +1 -0
  115. package/dist/app/renderStartupStatusCard.d.ts +7 -0
  116. package/dist/app/renderSubagentSummary.d.ts +6 -0
  117. package/dist/app/renderTurnCompletionSeparator.d.ts +1 -0
  118. package/dist/app/renderWorkflowSummary.d.ts +1 -0
  119. package/dist/app/resolveInputBackground.d.ts +3 -0
  120. package/dist/app/resolveStartupProviderQuota.d.ts +3 -0
  121. package/dist/app/resolveStartupSessionId.d.ts +18 -0
  122. package/dist/app/resolveTerminalColorLevel.d.ts +2 -0
  123. package/dist/app/resolveTerminalStyle.d.ts +1 -0
  124. package/dist/app/resolveTerminalTheme.d.ts +5 -0
  125. package/dist/app/runApp.d.ts +27 -0
  126. package/dist/app/runClipboardCommand.d.ts +8 -0
  127. package/dist/app/sanitizeTerminalText.d.ts +1 -0
  128. package/dist/app/shortenHomePath.d.ts +2 -0
  129. package/dist/app/sortSubagentsForDisplay.d.ts +2 -0
  130. package/dist/app/subagentElapsedMs.d.ts +2 -0
  131. package/dist/app/subagentLogMessageText.d.ts +2 -0
  132. package/dist/app/surfaceThemedLine.d.ts +3 -0
  133. package/dist/app/truncatePathToWidth.d.ts +1 -0
  134. package/dist/app/truncateTextForDisplay.d.ts +5 -0
  135. package/dist/app/truncateUtf8BytesForDisplay.d.ts +1 -0
  136. package/dist/app/upsertSubagentSummary.d.ts +2 -0
  137. package/dist/app/workflowDisplay.d.ts +2 -0
  138. package/dist/client/HappyAgentEventHub.d.ts +25 -0
  139. package/dist/client/RemoteAgent.d.ts +74 -0
  140. package/dist/client/RemoteAgentRunError.d.ts +4 -0
  141. package/dist/client/RemoteTerminalAttachment.d.ts +19 -0
  142. package/dist/client/RemoteTerminalClientReplica.d.ts +14 -0
  143. package/dist/client/ensureLocalProtocolServer.d.ts +10 -0
  144. package/dist/client/fetchProviderQuotas.d.ts +10 -0
  145. package/dist/client/index.d.ts +8 -0
  146. package/dist/client/loadAgentCatalog.d.ts +32 -0
  147. package/dist/config/createProjectConfigSecurityNotice.d.ts +10 -0
  148. package/dist/config/defaultConfig.d.ts +2 -0
  149. package/dist/config/getDefaultGlobalConfigPath.d.ts +1 -0
  150. package/dist/config/getDefaultLocalConfigPath.d.ts +1 -0
  151. package/dist/config/getDefaultRuntimeConfigPath.d.ts +1 -0
  152. package/dist/config/getHappyConfigDirectory.d.ts +1 -0
  153. package/dist/config/getHappyTerminalHome.d.ts +1 -0
  154. package/dist/config/index.d.ts +15 -0
  155. package/dist/config/loadConfig.d.ts +2 -0
  156. package/dist/config/mergeConfigValues.d.ts +2 -0
  157. package/dist/config/parseConfigToml.d.ts +7 -0
  158. package/dist/config/readConfigFile.d.ts +2 -0
  159. package/dist/config/readProjectConfigFile.d.ts +2 -0
  160. package/dist/config/resolveConfigPaths.d.ts +2 -0
  161. package/dist/config/runtimeConfigLock.d.ts +1 -0
  162. package/dist/config/types.d.ts +74 -0
  163. package/dist/config/updateRuntimeConfig.d.ts +2 -0
  164. package/dist/config/updateRuntimePreferences.d.ts +2 -0
  165. package/dist/config/withoutProjectMachineSettings.d.ts +7 -0
  166. package/dist/config/writeRuntimeConfig.d.ts +3 -0
  167. package/dist/config/writeRuntimeConfigDefaults.d.ts +2 -0
  168. package/dist/daemon/createUnixSocketFetch.d.ts +2 -0
  169. package/dist/daemon/daemonPid.d.ts +11 -0
  170. package/dist/daemon/ensureHappyAgentBinary.d.ts +15 -0
  171. package/dist/daemon/ensureLocalProtocolServer.d.ts +24 -0
  172. package/dist/daemon/getHappyDaemonPaths.d.ts +16 -0
  173. package/dist/daemon/happyAgentBinaryConfig.d.ts +15 -0
  174. package/dist/daemon/index.d.ts +5 -0
  175. package/dist/daemon/runDaemonCommand.d.ts +4 -0
  176. package/dist/debug/DebugLog.d.ts +11 -0
  177. package/dist/debug/closeNodeInspector.d.ts +1 -0
  178. package/dist/debug/createDebugJsonReplacer.d.ts +1 -0
  179. package/dist/debug/createRequestDebugDirectory.d.ts +1 -0
  180. package/dist/debug/getDebugRootDirectory.d.ts +1 -0
  181. package/dist/debug/getNodeInspectorUrl.d.ts +1 -0
  182. package/dist/debug/index.d.ts +8 -0
  183. package/dist/debug/openNodeInspector.d.ts +1 -0
  184. package/dist/debug/registerHappyTerminalDebugRoot.d.ts +5 -0
  185. package/dist/docs/DESIGN.md +2191 -0
  186. package/dist/docs/README.md +93 -0
  187. package/dist/docs/agents-and-collaboration.md +365 -0
  188. package/dist/docs/architecture.md +468 -0
  189. package/dist/docs/extending.md +561 -0
  190. package/dist/docs/happy.md +386 -0
  191. package/dist/docs/permissions-and-sandbox.md +342 -0
  192. package/dist/docs/workspaces.md +281 -0
  193. package/dist/errorToMessage.d.ts +1 -0
  194. package/dist/formatCliFailure.d.ts +10 -0
  195. package/dist/index.d.ts +3 -0
  196. package/dist/index.js +17151 -0
  197. package/dist/main.js +18946 -0
  198. package/dist/observability/daemonContext.d.ts +16 -0
  199. package/dist/observability/index.d.ts +1 -0
  200. package/dist/processes/BoundedOutputBuffer.d.ts +20 -0
  201. package/dist/processes/NativeProcessManager.d.ts +66 -0
  202. package/dist/processes/ProcessGroupReaper.d.ts +32 -0
  203. package/dist/processes/index.d.ts +7 -0
  204. package/dist/processes/isProcessRunning.d.ts +1 -0
  205. package/dist/processes/isTargetProcessAlive.d.ts +1 -0
  206. package/dist/processes/killProcessTree.d.ts +2 -0
  207. package/dist/processes/resolveSystemShell.d.ts +1 -0
  208. package/dist/processes/startProcessTransport.d.ts +33 -0
  209. package/dist/processes/types.d.ts +41 -0
  210. package/dist/processes/waitForProcessExit.d.ts +2 -0
  211. package/dist/protocol/AppletProtocol.d.ts +101 -0
  212. package/dist/protocol/Attachment.d.ts +96 -0
  213. package/dist/protocol/ClientProtocolTypes.d.ts +481 -0
  214. package/dist/protocol/DocumentProtocol.d.ts +109 -0
  215. package/dist/protocol/EventId.d.ts +1 -0
  216. package/dist/protocol/FolderProtocol.d.ts +236 -0
  217. package/dist/protocol/FolderSharingProtocol.d.ts +97 -0
  218. package/dist/protocol/GitHubPluginProtocol.d.ts +43 -0
  219. package/dist/protocol/GlobalSecurityProtocol.d.ts +7 -0
  220. package/dist/protocol/HappyCloudProtocol.d.ts +178 -0
  221. package/dist/protocol/InstallationProtocol.d.ts +80 -0
  222. package/dist/protocol/OnboardingProtocol.d.ts +47 -0
  223. package/dist/protocol/P2pCredentialProtocol.d.ts +273 -0
  224. package/dist/protocol/P2pIdentityProtocol.d.ts +8 -0
  225. package/dist/protocol/P2pPairingProtocol.d.ts +68 -0
  226. package/dist/protocol/P2pProtocol.d.ts +185 -0
  227. package/dist/protocol/PluginWireTypes.d.ts +81 -0
  228. package/dist/protocol/ProfileProtocol.d.ts +127 -0
  229. package/dist/protocol/ProjectFileProtocol.d.ts +63 -0
  230. package/dist/protocol/ProjectProtocol.d.ts +646 -0
  231. package/dist/protocol/ProtocolVersion.d.ts +8 -0
  232. package/dist/protocol/ServiceNotice.d.ts +175 -0
  233. package/dist/protocol/SessionProtocol.d.ts +1156 -0
  234. package/dist/protocol/SharingProtocol.d.ts +159 -0
  235. package/dist/protocol/SlotProtocol.d.ts +270 -0
  236. package/dist/protocol/TimelineProtocol.d.ts +88 -0
  237. package/dist/protocol/WorkletProtocol.d.ts +159 -0
  238. package/dist/protocol/createEventIdFactory.d.ts +7 -0
  239. package/dist/protocol/index.d.ts +34 -0
  240. package/dist/readPackageVersion.d.ts +1 -0
  241. package/dist/readPackageVersion.js +16 -0
  242. package/dist/reportCliFailure.d.ts +2 -0
  243. package/dist/runHappyTerminal.d.ts +6 -0
  244. package/dist/terminal/index.d.ts +2 -0
  245. package/dist/terminal/types.d.ts +39 -0
  246. package/dist/types.d.ts +6 -0
  247. package/dist/writeStderrSync.d.ts +5 -0
  248. package/package.json +97 -0
@@ -0,0 +1,2191 @@
1
+ # Happy (2) plugin app design
2
+
3
+ This is the authoritative visual specification for a **plugin app**: the HTML
4
+ document a Happy (2) plugin ships and Happy renders inside a sandboxed iframe
5
+ (an MCP App view, or an applet served on its own isolated origin).
6
+
7
+ It is written to be followed literally. Every rule states a name, a number, or a
8
+ condition. Where a choice exists, this document makes it for you. If you are a
9
+ model generating a plugin app and you are unsure what something should look
10
+ like, the answer is in here; do not invent one.
11
+
12
+ ## 0. How to use this document
13
+
14
+ **Precedence.** Three contracts apply to a plugin app, in this order:
15
+
16
+ 1. The **MCP Apps specification** (`@modelcontextprotocol/ext-apps`). It defines
17
+ the handshake, the host context, and the closed set of style variables. Never
18
+ violate it.
19
+ 2. **This document.** It says how a Happy plugin app should look and behave
20
+ inside that specification.
21
+ 3. Your plugin's own product decisions — content, information architecture,
22
+ feature set.
23
+
24
+ The repository's root `DESIGN.md` is the contract for **Happy's own native UI**,
25
+ written and reviewed inside the Happy codebase. It is _not_ the contract for a
26
+ plugin app and you do not need to read it. This document deliberately restates
27
+ every shared value (grid, radii, control heights, type scale) so a plugin app is
28
+ self-contained. Where a number appears in both, it is the same number.
29
+
30
+ **The one-sentence summary.** A Happy plugin app is a quiet, dense, neutral
31
+ desktop surface built from flex rows and columns on a 4 px grid, painted only
32
+ with host-provided CSS variables, with exactly one accent colour used only where
33
+ something is interactive or selected.
34
+
35
+ **Read at minimum:** §1 (host contract), §2 (surfaces and sizes), §5 (variable
36
+ reference), §6 (typography), §11.6 (what to render before you are connected),
37
+ §16 (anti-patterns), §18 (checklist). §17 is a complete working baseline you can
38
+ copy verbatim.
39
+
40
+ ---
41
+
42
+ ## 1. The host contract
43
+
44
+ ### 1.1 How appearance reaches your app
45
+
46
+ Your app runs in an iframe on a different origin from Happy. It cannot read
47
+ Happy's stylesheets, DOM, or fonts. Everything about appearance arrives as data
48
+ in the **host context**, over the MCP Apps postMessage bridge:
49
+
50
+ | Field | What it carries |
51
+ | ------------------------- | ----------------------------------------------------------------------- |
52
+ | `theme` | `"light"` or `"dark"` — the appearance Happy is currently rendering in. |
53
+ | `styles.variables` | The standard MCP Apps CSS variables, resolved from Happy's live theme. |
54
+ | `happy2/styles.variables` | Happy's extension: the roles the standard set has no key for (§5.3). |
55
+ | `displayMode` | `"inline"` or `"fullscreen"` — which surface you are on (§2). |
56
+ | `containerDimensions` | The space Happy allocated you **at connect time only** — see below. |
57
+ | `platform` | Always `"desktop"` in Happy. |
58
+ | `deviceCapabilities` | Always `{ touch: false, hover: true }` in Happy. |
59
+ | `locale`, `timeZone` | For formatting dates and numbers. Never hard-code a format. |
60
+
61
+ > **`containerDimensions` is not live.** Happy re-sends the host context when the
62
+ > appearance changes, not when the window resizes, so this value goes stale the
63
+ > moment the user drags the window edge. Use it to choose an initial layout at
64
+ > most. **Never lay out from it in JavaScript.** Your size comes from CSS —
65
+ > `height: 100%` and a flex column — and from container queries (§4.5).
66
+
67
+ The SDK applies all of this for you. In a React app:
68
+
69
+ ```tsx
70
+ import { useHappyApp } from "happy2-plugin-sdk/app";
71
+
72
+ function App() {
73
+ const { app, error, hostContext, isConnected } = useHappyApp({
74
+ appInfo: { name: "my-plugin-app", version: "1.0.0" },
75
+ autoResize: true,
76
+ });
77
+ // Nothing else is needed for theming.
78
+ }
79
+ ```
80
+
81
+ `useHappyApp` calls the official `useHostStyles`, which sets
82
+ `data-theme="light" | "dark"` and `style.colorScheme` on `<html>` and writes
83
+ every standard variable there as an inline custom property; and it calls
84
+ Happy's `useHappyStyleVariables`, which writes the `--happy-*` variables the
85
+ same way.
86
+
87
+ **Your first paint is not themed.** Connecting is an asynchronous handshake over
88
+ `postMessage`. Your app mounts and paints _before_ it completes — with
89
+ `isConnected === false` and not one host variable set — then repaints when the
90
+ context arrives, usually within a frame or two. This is why the fallback block in
91
+ §5.4 is mandatory rather than a nicety: without it the app flashes unstyled, and
92
+ in a host that never sends styles it stays that way. §11.6 says what to render
93
+ during the handshake.
94
+
95
+ ### 1.2 The theme can change at any moment
96
+
97
+ Happy re-sends the host context whenever the resolved appearance changes: the
98
+ operating system switches between light and dark, or the user picks an explicit
99
+ appearance inside Happy. Your app is **not** remounted and **not** reloaded.
100
+
101
+ This has one hard consequence:
102
+
103
+ > **Every colour in your app must be a `var(--…)` reference that resolves through
104
+ > the host variables.** A colour you computed once in JavaScript, baked into a
105
+ > generated SVG, or hard-coded in a class name will be wrong the moment the theme
106
+ > changes, and there will be no second chance to fix it.
107
+
108
+ Corollaries:
109
+
110
+ - Do not read a variable with `getComputedStyle` and store the value.
111
+ - Do not branch on `theme` in JavaScript to pick a colour. Let CSS do it.
112
+ - Do not use `@media (prefers-color-scheme: …)` to pick colours. The user may
113
+ have overridden the appearance inside Happy, in which case the media query and
114
+ the host disagree and the host is right.
115
+ - **Do not write theme-conditional rules at all.** The variables already encode
116
+ the difference; a rule keyed on `[data-theme="dark"]` is both unnecessary and a
117
+ trap, because that attribute exists only after a host connects, so the rule
118
+ silently never fires in a standalone browser tab. If a value genuinely has no
119
+ variable — a hand-drawn illustration's two-tone fill, say — express it with
120
+ `light-dark()`, which works in both situations.
121
+ - `light-dark()` **is** safe: the host sets `color-scheme` on `<html>`, so
122
+ `light-dark(a, b)` follows the host's decision, including an in-app override.
123
+ This is the recommended way to write a _fallback_ (§5.4).
124
+
125
+ ### 1.3 What the host will never give you
126
+
127
+ - Happy's fonts as files, its component CSS, its class names, or its icon fonts.
128
+ - Arbitrary CSS. There is no channel for the host to inject rules into your
129
+ document. Everything you paint, you paint yourself, from the variables.
130
+ - Any variable outside the two documented sets. Do not guess at names such as
131
+ `--happy-sidebar-width`; they do not exist and never resolve.
132
+
133
+ ### 1.4 You are app content, not Happy chrome
134
+
135
+ Happy already draws the frame around you: a header carrying the app's glyph,
136
+ title, and controls (40 px on a message card, 56 px on a page or overlay); the
137
+ window title bar; the feature rail; the modal card and its scrim. Your document
138
+ is the region _inside_ that.
139
+
140
+ **Do not imitate host chrome.** Specifically, never render:
141
+
142
+ - A window title bar, traffic-light buttons, or a drag region.
143
+ - A duplicate of the app's own title in a bar across the top of your document.
144
+ Happy is already showing it, one hairline above you.
145
+ - A close, minimise, maximise, reload, or "open in new window" control.
146
+ - A global app-level navigation rail or sidebar of _Happy's_ features.
147
+ - A backdrop or scrim covering the whole viewport for a "modal" that is really
148
+ the whole app. If your app needs to be a dialog, request it from Happy.
149
+
150
+ You _may_ render a toolbar or a header **for your own content** — a list's
151
+ filter row, a document's breadcrumb — as long as it reads as part of the
152
+ content, not as a second window frame. See §9.6.
153
+
154
+ ---
155
+
156
+ ## 2. Surfaces, dimensions, and gutters
157
+
158
+ Happy is **desktop only**. There is no mobile viewport, no touch input, and no
159
+ phone layout. Do not write `@media (max-width: 480px)` breakpoints, do not
160
+ enlarge hit targets for fingers, and do not build a bottom tab bar.
161
+
162
+ ### 2.1 The five surfaces
163
+
164
+ | # | Surface | `displayMode` | Host header | Host gutter around you | Typical width | Height ownership |
165
+ | --- | ----------------------- | ------------- | ----------- | ---------------------------------------------------------- | ----------------------------- | ---------------- |
166
+ | 1 | Message card (inline) | `inline` | 40 px | 0, or 8 px + 1 px hairline when the app asked for a border | the chat message column | **you**, 120–800 |
167
+ | 2 | Workspace page | `fullscreen` | 56 px | none, flush | main region width | the host |
168
+ | 3 | Modal card | `fullscreen` | 56 px | none, flush | `min(920px, window − 48) − 2` | the host |
169
+ | 4 | Fullscreen overlay | `fullscreen` | 56 px | none, flush | `window − 48 − 2` | the host |
170
+ | 5 | Happy Agent plugin page | `fullscreen` | none | none, flush | the whole allocated region | the host |
171
+
172
+ `hostContext.containerDimensions` reports the initial allocation as one of
173
+ `{ width, height }`, `{ width, maxHeight }`, or `{ maxWidth, maxHeight }`. It
174
+ does not track resizing — see the note in §1.1.
175
+
176
+ Surfaces 2–4 are flush: no inset, no border, no rounded corner from the host. The
177
+ seam between Happy's chrome and your document is one hairline, so your outermost
178
+ background should normally be `--color-background-primary` (§7.1), which makes
179
+ that seam invisible.
180
+
181
+ **You cannot tell surfaces 2–5 apart, and you must not need to.** All four report
182
+ `displayMode: "fullscreen"`; nothing in the host context says whether you are a
183
+ workspace page, a modal card, or a whole window. Design one fullscreen layout that
184
+ is correct at every size in §2.4 and it will be right on all four. The only
185
+ distinction that reaches you is `inline` versus `fullscreen`, and it matters for
186
+ exactly one reason: who owns your height (§2.2, §2.3).
187
+
188
+ ### 2.2 Inline (surface 1) — you own your height
189
+
190
+ On a chat message card, Happy sizes the frame to the height your document
191
+ reports. The SDK does this for you when you pass `autoResize: true`.
192
+
193
+ - Default before you report: **360 px**.
194
+ - Clamped to **120 px minimum, 800 px maximum**. Content taller than 800 px
195
+ scrolls _inside_ your document; design for that, do not fight it.
196
+ - `containerDimensions` gives you `maxHeight: 800`.
197
+
198
+ Therefore: on inline, **`<body>` must size to its content**. Do not set
199
+ `height: 100%` or `100vh` on `html`/`body`, or you will report 800 px forever and
200
+ show a mostly empty card.
201
+
202
+ ### 2.3 Fullscreen (surfaces 2–5) — the host owns your height
203
+
204
+ The frame is exactly the region Happy allocated. Your document must fill it and
205
+ manage its own overflow.
206
+
207
+ ```css
208
+ html,
209
+ body {
210
+ height: 100%;
211
+ margin: 0;
212
+ }
213
+ #root {
214
+ display: flex;
215
+ flex-direction: column;
216
+ height: 100%;
217
+ min-height: 0;
218
+ }
219
+ ```
220
+
221
+ ### 2.4 The smallest size you must survive
222
+
223
+ Happy's Electron window minimum is **720 × 480**. Subtract the 64 px feature
224
+ rail, a navigation sidebar that can be as narrow as 250 px, the 56 px window
225
+ title row, and the 56 px host app header, and the honest floor is:
226
+
227
+ > **A plugin app must remain usable and unbroken at 400 × 360 CSS pixels, and
228
+ > must look correct at 640 × 480 and above.**
229
+
230
+ Design the primary layout for **640–1180 px** wide. Below 640 px, collapse
231
+ optional columns; do not introduce a different design.
232
+
233
+ ### 2.5 Gutters
234
+
235
+ Except for the optional 8 px on a bordered inline card, the host region is flush,
236
+ so your document supplies its own gutter:
237
+
238
+ | Context | Gutter (padding) |
239
+ | ----------------------------------------- | ---------------- |
240
+ | Inline message card, outer edge | **12 px** |
241
+ | Workspace page / overlay, outer edge | **16 px** |
242
+ | Wide page (≥ 900 px), outer edge | **24 px** |
243
+ | Inside a card | **16 px** |
244
+ | Inside a card in a dense card grid | **12 px** |
245
+ | Inside a compact list row | **8 px 12 px** |
246
+ | Between a section heading and its content | **8 px** |
247
+
248
+ Never use an outer gutter larger than 24 px. Never use 0 — content must not
249
+ touch the frame edge.
250
+
251
+ If your content has a natural reading width, cap the _inner_ measure and centre
252
+ it; keep the scroll container full-bleed (§4).
253
+
254
+ ```css
255
+ .page-measure {
256
+ width: 100%;
257
+ max-width: 720px;
258
+ margin-inline: auto;
259
+ }
260
+ ```
261
+
262
+ ---
263
+
264
+ ## 3. The grid: 4 / 8 / 16
265
+
266
+ The scale applies to **layout spacing**: the `gap` between siblings, the padding
267
+ of a container around its children, the page gutter, and the space between
268
+ blocks. Every such value is one of:
269
+
270
+ | Value | Use |
271
+ | ----- | ------------------------------------------------------- |
272
+ | 4 px | Tight vertical pairs, chip-to-chip |
273
+ | 8 px | **The default gap.** Between controls, between cards |
274
+ | 12 px | Between a row's columns, between fields in a dense form |
275
+ | 16 px | Between content blocks, card padding, page gutter |
276
+ | 24 px | Between major sections, wide page gutter |
277
+ | 32 px | Around an empty state, above a page's first section |
278
+ | 48 px | Rare; vertical breathing room in a nearly empty page |
279
+
280
+ **Control internals are not on this scale.** The padding inside a button, the
281
+ gap between a button's icon and its label, the padding inside an input, the
282
+ height of a chip — these are fixed per component in §9 and are whatever number
283
+ makes the component's declared height come out exactly right. Copy them from §9
284
+ verbatim; do not "correct" a `padding: 0 14px` to `0 16px`.
285
+
286
+ Rules:
287
+
288
+ - `0` is always allowed, and is the right answer more often than it looks: a
289
+ stacked pair of text lines is separated by its line boxes, not by a `gap`, and a
290
+ hairline-separated list uses `gap: 0` (§8). Anything greater than zero is one of
291
+ the seven values above.
292
+ - Every length is a whole number of CSS pixels. Never use `em`/`rem` for spacing,
293
+ and never a fractional pixel.
294
+ - 1 px is for hairline borders. 2 px is for a focus ring and its offset.
295
+ - Border-box everywhere: `*, *::before, *::after { box-sizing: border-box; }`.
296
+ - A declared size must be the rendered size. If a row promises 36 px, its
297
+ padding, border, and line height must add to 36 px.
298
+ - Prefer integer positions so 1 px borders stay crisp at 2× device scale.
299
+
300
+ ---
301
+
302
+ ## 4. Layout: flexbox, and scrolling
303
+
304
+ ### 4.1 Flexbox is the default
305
+
306
+ Use flexbox for every row, column, stack, toolbar, list, and centred box.
307
+
308
+ ```css
309
+ .row {
310
+ display: flex;
311
+ align-items: center;
312
+ gap: 8px;
313
+ }
314
+ .column {
315
+ display: flex;
316
+ flex-direction: column;
317
+ gap: 8px;
318
+ }
319
+ .center {
320
+ display: flex;
321
+ align-items: center;
322
+ justify-content: center;
323
+ }
324
+ ```
325
+
326
+ Use CSS Grid **only** for a genuine two-dimensional grid of related tracks: a
327
+ data table's cells, or a media matrix with equal row and column tracks. When you
328
+ do, put a comment beside the declaration explaining the geometry flexbox cannot
329
+ express.
330
+
331
+ Never use floats, `inline-block` spacing hacks, layout tables, or absolute
332
+ positioning for layout. Absolute positioning is for overlays, popovers, and
333
+ badges that intentionally leave the flow.
334
+
335
+ ### 4.2 The parent owns the spacing
336
+
337
+ Spacing between siblings belongs to the flex parent, via `gap`. A child never
338
+ gives itself an external margin to separate itself from a sibling it does not
339
+ know about.
340
+
341
+ ```css
342
+ /* Correct */
343
+ .toolbar {
344
+ display: flex;
345
+ gap: 8px;
346
+ }
347
+ .toolbar > * {
348
+ margin: 0;
349
+ }
350
+
351
+ /* Wrong */
352
+ .toolbar-button + .toolbar-button {
353
+ margin-left: 8px;
354
+ }
355
+ .toolbar-button:not(:last-child) {
356
+ margin-right: 8px;
357
+ }
358
+ ```
359
+
360
+ **The one legitimate margin** is `margin-inline-start: auto` on a flex child, to
361
+ push it and everything after it to the far end of the row. That is positioning,
362
+ not spacing, and it is the correct idiom — do not simulate it with a spacer
363
+ element, and do not reach for `justify-content: space-between`, which spreads
364
+ _every_ child rather than only the tail.
365
+
366
+ ```css
367
+ /* Preferred: let the middle column absorb the slack. */
368
+ .toolbar-title {
369
+ flex: 1 1 auto;
370
+ min-width: 0;
371
+ }
372
+ /* Or push the tail explicitly when there is no such column. */
373
+ .toolbar-count {
374
+ margin-inline-start: auto;
375
+ }
376
+ ```
377
+
378
+ A conditional child must live in the same flex flow as its neighbours. When it is
379
+ absent it contributes neither a box nor a gap; when it appears, the parent's
380
+ `gap` separates it on both sides automatically. Do not wrap optional content in a
381
+ nested div that creates its own spacing island.
382
+
383
+ ### 4.3 A flex child that can shrink needs `min-width: 0`
384
+
385
+ This is the single most common layout bug. A flex item's default minimum size is
386
+ its content, so long text pushes its siblings out of the row instead of
387
+ ellipsizing.
388
+
389
+ ```css
390
+ .row-main {
391
+ flex: 1 1 auto;
392
+ min-width: 0;
393
+ } /* the column that may shrink */
394
+ .row-title {
395
+ overflow: hidden;
396
+ text-overflow: ellipsis;
397
+ white-space: nowrap;
398
+ }
399
+ .row-aside {
400
+ flex: none;
401
+ } /* fixed-size trailing content */
402
+ ```
403
+
404
+ The same applies vertically: a flex child that contains a scroll area needs
405
+ `min-height: 0`.
406
+
407
+ ### 4.4 Scrolling
408
+
409
+ Exactly **one** element in a fullscreen layout owns vertical scrolling. It fills
410
+ the region its parent gives it and has **zero padding and zero margin** — its
411
+ viewport and scrollbar run edge to edge. All spacing, maximum widths, and
412
+ centring belong to an inner wrapper.
413
+
414
+ ```css
415
+ .scrollport {
416
+ flex: 1 1 auto;
417
+ min-height: 0;
418
+ width: 100%;
419
+ overflow-y: auto;
420
+ overflow-x: hidden;
421
+ /* no padding, no margin */
422
+ }
423
+ .scrollport-content {
424
+ display: flex;
425
+ flex-direction: column;
426
+ gap: 12px;
427
+ padding: 16px;
428
+ }
429
+ ```
430
+
431
+ Rules:
432
+
433
+ - Never put `overflow: auto` on `<body>` in a fullscreen surface; give it to the
434
+ designated scrollport so the header and footer stay fixed.
435
+ - Never nest scroll areas on the same axis. Two vertical scrollbars in one view
436
+ is a defect.
437
+ - Horizontal scrolling is allowed only for a wide data table, and only on the
438
+ table's own container.
439
+ - Keep the full painted extent of a focus ring inside the content wrapper's
440
+ padding, so scrolling cannot clip it. A 2 px ring with a 2 px offset needs at
441
+ least 4 px of clearance — the 16 px gutter covers it.
442
+ - Style the scrollbar with the host thumb colour, or leave it native. Do not hide
443
+ it.
444
+
445
+ ```css
446
+ .scrollport {
447
+ scrollbar-width: thin;
448
+ scrollbar-color: var(--happy-scrollbar-thumb, rgb(150 150 150 / 0.4)) transparent;
449
+ }
450
+ ```
451
+
452
+ ### 4.5 Responsive behaviour inside a desktop panel
453
+
454
+ Respond to the **container**, not the viewport, and only to collapse or reflow —
455
+ never to switch to a different design.
456
+
457
+ ```css
458
+ .page {
459
+ container-type: inline-size;
460
+ }
461
+
462
+ /* Below 640px the metadata column folds under the main column. */
463
+ @container (max-width: 640px) {
464
+ .split {
465
+ flex-direction: column;
466
+ }
467
+ .split-aside {
468
+ width: 100%;
469
+ }
470
+ }
471
+ ```
472
+
473
+ Permitted responses to a narrow container, in order of preference:
474
+
475
+ 1. Let a flex row wrap (`flex-wrap: wrap`) at a documented gap.
476
+ 2. Hide a genuinely secondary column (never the primary action).
477
+ 3. Reduce a 3-column card grid to 2, then 1.
478
+ 4. Swap a side-by-side label/field pair to stacked.
479
+
480
+ Forbidden: hiding the primary action, replacing the layout with a "mobile" one,
481
+ introducing a hamburger menu, shrinking type below the minimums in §6.
482
+
483
+ ---
484
+
485
+ ## 5. Colour: the complete variable reference
486
+
487
+ **Every colour in your app comes from this section.** A raw hex value, named
488
+ colour, or `rgb()` literal in your CSS is a defect, with exactly one exception:
489
+ the fallback inside `var(--x, fallback)` (§5.4).
490
+
491
+ The two schemes are Happy's: light is white and `#f5f5f5` neutrals with black
492
+ primary actions; dark is a `#1e1e1e` canvas with `#212121` surfaces and, again,
493
+ black primary actions. You never write those values — they are given here only so
494
+ you can predict what a variable will look like.
495
+
496
+ ### 5.1 Standard MCP Apps variables — colour
497
+
498
+ Set on `<html>` by the official host-styles hook. Resolved live from Happy's
499
+ theme, so they change with the appearance.
500
+
501
+ | Variable | Meaning in Happy | Light | Dark |
502
+ | ------------------------------ | ---------------------------------------------------------------------- | ------------- | ------------- |
503
+ | `--color-background-primary` | The surface a card or page body sits on | `#ffffff` | `#212121` |
504
+ | `--color-background-secondary` | A recessed panel: table head, sidebar, code well | `#f8f8f8` | `#171717` |
505
+ | `--color-background-tertiary` | A pressed/active fill, or a deeper inset | `#f0f0f2` | `#2c2c2e` |
506
+ | `--color-background-inverse` | The primary-action fill (black in both schemes) | `#000000` | `#000000` |
507
+ | `--color-background-ghost` | Hover wash over any surface (translucent) | 8 % black | 8 % white |
508
+ | `--color-background-info` | Soft informational fill (12 % accent over surface) | pale blue | dim blue |
509
+ | `--color-background-success` | Soft success fill (14 % green over surface) | pale green | dim green |
510
+ | `--color-background-warning` | Soft warning fill | `#fff8f0` | 15 % orange |
511
+ | `--color-background-danger` | Soft error fill | `#fff0f0` | 15 % red |
512
+ | `--color-background-disabled` | A disabled control's fill | `#f0f0f2` | `#2c2c2e` |
513
+ | `--color-text-primary` | Body and heading text | `#000000` | `#ffffff` |
514
+ | `--color-text-secondary` | Supporting text, labels, captions | `#49454f` | `#cac4d0` |
515
+ | `--color-text-tertiary` | Metadata, timestamps, counts: 70 % of secondary over the surface | `#807d84` | `#97939c` |
516
+ | `--color-text-inverse` | Text on `--color-background-inverse` | `#ffffff` | `#ffffff` |
517
+ | `--color-text-ghost` | Text on a ghost/hover fill | `#49454f` | `#cac4d0` |
518
+ | `--color-text-info` | Informational text and the accent for text | `#007aff` | `#0a84ff` |
519
+ | `--color-text-success` | Success text | `#34c759` | `#32d74b` |
520
+ | `--color-text-warning` | Warning text | `#ff9500` | `#ffab00` |
521
+ | `--color-text-danger` | Destructive/error text | `#f44336` | `#f48fb1` |
522
+ | `--color-text-disabled` | Disabled label: 45 % of secondary over the surface | `#adabb0` | `#6d6a70` |
523
+ | `--color-border-primary` | **The default hairline.** Card, row, input border | `#eaeaea` | `#292929` |
524
+ | `--color-border-secondary` | The one divider stronger than the hairline: 20 % text over the surface | `#cccccc` | `#4d4d4d` |
525
+ | `--color-border-tertiary` | Identical to primary. Use primary. | `#eaeaea` | `#292929` |
526
+ | `--color-border-inverse` | Border on an inverse fill | `#000000` | `#000000` |
527
+ | `--color-border-ghost` | `transparent`, for a border that only reserves space | `transparent` | `transparent` |
528
+ | `--color-border-info` | Informational outline | `#007aff` | `#0a84ff` |
529
+ | `--color-border-success` | Success outline | `#34c759` | `#32d74b` |
530
+ | `--color-border-warning` | Warning outline | `#ff9500` | `#ff9f0a` |
531
+ | `--color-border-danger` | Error outline | `#f44336` | `#f48fb1` |
532
+ | `--color-border-disabled` | Disabled control outline | `#eaeaea` | `#292929` |
533
+ | `--color-ring-primary` | **The focus ring and the interactive accent** | `#007aff` | `#0a84ff` |
534
+ | `--color-ring-secondary` | A neutral ring, the secondary text colour | `#49454f` | `#cac4d0` |
535
+ | `--color-ring-inverse` | Ring against an inverse fill | `#000000` | `#000000` |
536
+ | `--color-ring-info` | Same as ring-primary | `#007aff` | `#0a84ff` |
537
+ | `--color-ring-success` | Success ring | `#34c759` | `#32d74b` |
538
+ | `--color-ring-warning` | Warning ring | `#ff9500` | `#ff9f0a` |
539
+ | `--color-ring-danger` | Error ring | `#f44336` | `#f48fb1` |
540
+
541
+ ### 5.2 Standard MCP Apps variables — type, shape, elevation
542
+
543
+ | Variable | Value | Use |
544
+ | ---------------------------------------------------------- | -------------------------- | ----------------------------------------- |
545
+ | `--font-sans` | Happy's UI stack | All interface text |
546
+ | `--font-mono` | Happy's mono stack | Code, IDs, hashes, log output |
547
+ | `--font-weight-normal` / `-medium` / `-semibold` / `-bold` | 400 / 500 / 600 / 700 | See §6 |
548
+ | `--font-text-xs-size` / `-line-height` | 11 / 16 px | Micro-labels, table column heads |
549
+ | `--font-text-sm-size` / `-line-height` | 12 / 18 px | Captions, metadata, chips |
550
+ | `--font-text-md-size` / `-line-height` | 14 / 20 px | **Body default** |
551
+ | `--font-text-lg-size` / `-line-height` | 16 / 24 px | Lead paragraph, prominent value |
552
+ | `--font-heading-xs-size` / `-line-height` | 13 / 18 px | Section label |
553
+ | `--font-heading-sm-size` / `-line-height` | 15 / 20 px | Card title, row title |
554
+ | `--font-heading-md-size` / `-line-height` | 17 / 22 px | Page title on a small surface |
555
+ | `--font-heading-lg-size` / `-line-height` | 20 / 26 px | Page title |
556
+ | `--font-heading-xl-size` / `-line-height` | 24 / 30 px | Rare; a landing or empty-state headline |
557
+ | `--font-heading-2xl-size` / `-line-height` | 28 / 34 px | Do not use in a plugin app |
558
+ | `--font-heading-3xl-size` / `-line-height` | 34 / 40 px | Do not use in a plugin app |
559
+ | `--border-radius-xs` | 6 px | Chips, small inputs |
560
+ | `--border-radius-sm` | 6 px | **Controls: buttons, inputs, menu items** |
561
+ | `--border-radius-md` | 8 px | **Content blocks, wells, list rows** |
562
+ | `--border-radius-lg` | 10 px | **Cards** |
563
+ | `--border-radius-xl` | 14 px | Large shells and dialog-like panels |
564
+ | `--border-radius-full` | 999 px | Pills, avatars, progress tracks |
565
+ | `--border-width-regular` | 1 px | Every hairline |
566
+ | `--shadow-hairline` | `0 0 0 1px <hairline>` | A ring instead of a border |
567
+ | `--shadow-sm` | `0 1px 2px <10 % black>` | A card lifted off the surface |
568
+ | `--shadow-md` | `0 4px 12px <24 % black>` | A popover or dropdown |
569
+ | `--shadow-lg` | `0 12px 32px <45 % black>` | An in-app dialog |
570
+
571
+ ### 5.3 Happy's extension: `--happy-*`
572
+
573
+ Delivered under the namespaced `happy2/styles` host-context member and applied by
574
+ the SDK. These are the roles the standard vocabulary has no key for. They are all
575
+ live theme values and follow appearance changes exactly like the standard ones.
576
+
577
+ | Variable | Meaning | Light | Dark |
578
+ | ----------------------------- | ------------------------------------------------------------------ | ---------- | ---------- |
579
+ | `--happy-canvas` | The app canvas **behind** cards; use when your page is a card list | `#f5f5f5` | `#1e1e1e` |
580
+ | `--happy-header-background` | A content header strip's fill (your own, not the host's) | `#ffffff` | `#212121` |
581
+ | `--happy-header-text` | Text on that strip | `#18171c` | `#ffffff` |
582
+ | `--happy-selected-background` | The fill of a **selected** row or tab | `#eaeaea` | `#2c2c2e` |
583
+ | `--happy-link` | Hyperlink text (Happy teal, identical in both schemes) | `#2baccc` | `#2baccc` |
584
+ | `--happy-input-background` | A text field's fill | `#f5f5f5` | `#303030` |
585
+ | `--happy-input-text` | A text field's value | `#000000` | `#ffffff` |
586
+ | `--happy-input-placeholder` | A text field's placeholder | `#999999` | `#8e8e93` |
587
+ | `--happy-code-background` | The code/diff/log surface | `#f6f8fa` | `#161b22` |
588
+ | `--happy-scrim` | Backdrop behind an in-app overlay you own | 48 % black | 48 % black |
589
+ | `--happy-scrollbar-thumb` | Custom scrollbar thumb | 40 % grey | 40 % grey |
590
+ | `--happy-shadow-color` | The base elevation tint, for a shadow you compose yourself | 10 % black | 10 % black |
591
+
592
+ No other `--happy-*` name exists — the SDK applies these twelve names and nothing
593
+ else, discards any value that is not a colour, and removes a variable that a later
594
+ host context stops sending. If you need a role that is not listed, build it from
595
+ the ones that are, with `color-mix`:
596
+
597
+ ```css
598
+ /* A 12% accent wash for a selected-and-focused row. */
599
+ background: color-mix(in srgb, var(--color-ring-primary) 12%, var(--color-background-primary));
600
+ ```
601
+
602
+ ### 5.4 Fallbacks: declare them once, in one place
603
+
604
+ Every host variable can be absent — when your app is opened outside Happy, in a
605
+ bare browser tab during development, or by a host that has not sent styles yet.
606
+ Declare a fallback for each variable you use **exactly once**, in a `:root` block
607
+ that maps host names to your app's own short names. Downstream CSS then uses your
608
+ names with no fallbacks at all.
609
+
610
+ ```css
611
+ :root {
612
+ /* Standalone documents follow the OS; inside Happy the host has already set
613
+ `color-scheme`, so `light-dark()` below follows the host's decision. */
614
+ color-scheme: light dark;
615
+
616
+ --app-surface: var(--color-background-primary, light-dark(#ffffff, #212121));
617
+ --app-raised: var(--color-background-secondary, light-dark(#f8f8f8, #171717));
618
+ --app-canvas: var(--happy-canvas, light-dark(#f5f5f5, #1e1e1e));
619
+ --app-text: var(--color-text-primary, light-dark(#000000, #ffffff));
620
+ --app-muted: var(--color-text-secondary, light-dark(#49454f, #cac4d0));
621
+ --app-border: var(--color-border-primary, light-dark(#eaeaea, #292929));
622
+ --app-accent: var(--color-ring-primary, light-dark(#007aff, #0a84ff));
623
+ --app-radius-control: var(--border-radius-sm, 6px);
624
+ --app-radius-card: var(--border-radius-lg, 10px);
625
+ --app-font: var(--font-sans, system-ui, -apple-system, sans-serif);
626
+ --app-mono: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
627
+ }
628
+ ```
629
+
630
+ Two rules about this block:
631
+
632
+ - **Never alias a variable to itself.** `--happy-canvas: var(--happy-canvas, #f5f5f5)`
633
+ is a cycle; the property becomes invalid and everything using it falls back to
634
+ its initial value. Always map a host name to a _different_ local name.
635
+ - **Never repeat a fallback downstream.** `background: var(--app-surface)` — not
636
+ `var(--app-surface, #fff)`. One place to change, one place to be wrong.
637
+ - The component snippets in §9 write `var(--host-name, fallback)` inline so each
638
+ one can be read and copied on its own. That is a documentation convenience, not
639
+ the pattern. When you assemble an app, alias every host variable you use in the
640
+ `:root` block once — §17's block is the complete list — and rewrite the §9
641
+ snippets to use your local names with no fallback.
642
+
643
+ ### 5.5 Contrast
644
+
645
+ - Body text on any surface: at least **4.5 : 1**.
646
+ - Text at 18 px+ or 14 px+ bold, and any non-text indicator (borders that carry
647
+ meaning, focus rings, chart strokes): at least **3 : 1**.
648
+ - The host variables already satisfy this for the documented pairings — primary
649
+ and secondary text on primary/secondary/tertiary backgrounds. If you invent a
650
+ pairing with `color-mix`, measure it.
651
+ - Never put `--color-text-tertiary` or `--color-text-disabled` on anything other
652
+ than `--color-background-primary` or `--color-background-secondary`.
653
+ - Never rely on colour alone. A status must also carry a word or a glyph.
654
+
655
+ ---
656
+
657
+ ## 6. Typography
658
+
659
+ ### 6.1 Families
660
+
661
+ ```css
662
+ body {
663
+ font-family: var(--app-font);
664
+ }
665
+ code,
666
+ pre,
667
+ .mono {
668
+ font-family: var(--app-mono);
669
+ }
670
+ ```
671
+
672
+ `--font-sans` resolves to Happy's UI stack and `--font-mono` to its code stack.
673
+ The font _files_ are not available to your origin, so the stack ends in system
674
+ fonts; that is expected and correct. Do **not** load a web font, do not use
675
+ `@import url(https://fonts.googleapis.com/…)`, and do not embed a font as a data
676
+ URL. A plugin app that ships its own typeface looks foreign and costs the user a
677
+ network request from a sandboxed origin.
678
+
679
+ Set `font-synthesis: none` on `body` so a missing weight is not faked.
680
+
681
+ ### 6.2 The scale
682
+
683
+ Use these seven roles and nothing else. Always set `font-size` and `line-height`
684
+ together, both from the same row.
685
+
686
+ | Role | Size / line height | Weight | Colour | Use |
687
+ | ------------- | ------------------ | ------ | ------------------------ | ------------------------------------- |
688
+ | Page title | 20 / 26 px | 600 | `--color-text-primary` | One per document, at most |
689
+ | Section title | 15 / 20 px | 600 | `--color-text-primary` | Card and group headings |
690
+ | Section label | 13 / 18 px | 600 | `--color-text-secondary` | Small caps-free group label |
691
+ | Body | 14 / 20 px | 400 | `--color-text-primary` | **The default for everything** |
692
+ | Row title | 14 / 20 px | 500 | `--color-text-primary` | The first line of a list row |
693
+ | Body strong | 14 / 20 px | 600 | `--color-text-primary` | A value being emphasised in prose |
694
+ | Caption | 12 / 18 px | 400 | `--color-text-secondary` | Secondary line under a title |
695
+ | Micro | 11 / 16 px | 500 | `--color-text-tertiary` | Table column heads, chip text, counts |
696
+
697
+ - **11 px is the absolute minimum.** Never render text smaller.
698
+ - Weight vocabulary is 400, 500, 600 only. Do not use 700 in a plugin app; do not
699
+ use 300 or lighter.
700
+ - Do not use `text-transform: uppercase` on anything longer than a three-word
701
+ label, and never with letter-spacing above `0.04em`.
702
+ - `letter-spacing: -0.01em` is permitted on the page title and section title.
703
+ Nowhere else.
704
+ - Never justify text. Never centre a paragraph. Centre only a single-line label
705
+ inside a control or an empty state.
706
+
707
+ ### 6.3 Long text
708
+
709
+ ```css
710
+ .truncate {
711
+ overflow: hidden;
712
+ text-overflow: ellipsis;
713
+ white-space: nowrap;
714
+ }
715
+ .wrap-anywhere {
716
+ overflow-wrap: anywhere;
717
+ } /* URLs, IDs, file paths */
718
+ .clamp-2 {
719
+ display: -webkit-box;
720
+ -webkit-box-orient: vertical;
721
+ -webkit-line-clamp: 2;
722
+ overflow: hidden;
723
+ }
724
+ ```
725
+
726
+ Every single-line title in a row or card must truncate, and its flex parent must
727
+ carry `min-width: 0` (§4.3). Text that can contain an unbroken token — a URL, a
728
+ hash, a path — must use `overflow-wrap: anywhere` or truncate; it must never
729
+ widen its container.
730
+
731
+ ### 6.4 Numbers
732
+
733
+ Right-align numeric table columns and use tabular figures so digits line up:
734
+
735
+ ```css
736
+ .num {
737
+ font-variant-numeric: tabular-nums;
738
+ text-align: right;
739
+ }
740
+ ```
741
+
742
+ Format dates, times, and numbers with `Intl`, passing `hostContext.locale` and
743
+ `hostContext.timeZone`. Never hard-code `MM/DD/YYYY` or a currency symbol.
744
+
745
+ ---
746
+
747
+ ## 7. Surfaces, borders, radii, shadows
748
+
749
+ ### 7.1 Choosing a background
750
+
751
+ | You are painting | Background |
752
+ | ------------------------------------------------- | ------------------------------ |
753
+ | The document, when the content **is** the surface | `--color-background-primary` |
754
+ | The document, when it holds a list of cards | `--happy-canvas` |
755
+ | A card on the canvas | `--color-background-primary` |
756
+ | A recessed panel: table header, sidebar, well | `--color-background-secondary` |
757
+ | A code, log, or diff block | `--happy-code-background` |
758
+ | A hovered row | `--color-background-ghost` |
759
+ | A selected row or active tab | `--happy-selected-background` |
760
+ | A pressed control | `--color-background-tertiary` |
761
+ | A text input | `--happy-input-background` |
762
+
763
+ Default to the first row. A plugin app on a workspace page sits flush against
764
+ Happy's own `--surface`; painting `--color-background-primary` makes the seam
765
+ invisible, which is correct. Reach for `--happy-canvas` only when you genuinely
766
+ have separated cards.
767
+
768
+ ### 7.2 Borders
769
+
770
+ One hairline, everywhere: `1px solid var(--color-border-primary)`.
771
+
772
+ - Prefer a border over a shadow to separate things. Happy is a flat, quiet system.
773
+ - A card gets a border **or** `--shadow-sm`, never both.
774
+ - Between list rows use `border-bottom` on each row except the last, or better, a
775
+ flex `gap` with no border at all.
776
+ - To reserve space for a border that appears on hover or selection, use
777
+ `--color-border-ghost` (transparent) rather than a margin that shifts on hover.
778
+
779
+ ### 7.3 Radii
780
+
781
+ | Element | Radius |
782
+ | -------------------------------------------- | ------------------------- |
783
+ | Button, input, select, menu item, small chip | `--border-radius-sm` (6) |
784
+ | List row, content block, code well | `--border-radius-md` (8) |
785
+ | Card | `--border-radius-lg` (10) |
786
+ | A large panel or in-app dialog | `--border-radius-xl` (14) |
787
+ | Pill, avatar, progress track | `--border-radius-full` |
788
+
789
+ **Nested corners must be true parallel curves.** When a child's edge sits in a
790
+ rounded corner of its parent:
791
+
792
+ ```
793
+ inner radius = max(0, outer radius − inset)
794
+ ```
795
+
796
+ `inset` is the real distance between the two border-box edges — the parent's
797
+ border plus its padding. A 10 px card with a 1 px border and 12 px padding gives
798
+ a child sitting in the corner a radius of `max(0, 10 − 13) = 0`. A header strip
799
+ flush inside that card (1 px inset, no padding) gets `10 − 1 = 9 px`.
800
+
801
+ If the horizontal and vertical insets differ, do not compromise — change the
802
+ layout so they match.
803
+
804
+ ### 7.4 Shadows
805
+
806
+ Use a shadow only for something that genuinely floats above the page:
807
+
808
+ | Thing | Shadow |
809
+ | ---------------------------- | ---------------------- |
810
+ | A card resting on the canvas | none, or `--shadow-sm` |
811
+ | A dropdown, popover, tooltip | `--shadow-md` |
812
+ | An in-app dialog you own | `--shadow-lg` |
813
+
814
+ Never put a shadow on a button, input, row, chip, badge, or table. Never write a
815
+ shadow with your own colour; if you must compose one, tint it with
816
+ `--happy-shadow-color`.
817
+
818
+ ---
819
+
820
+ ## 8. Density and rhythm
821
+
822
+ Happy is an information-dense desktop tool, not a marketing page.
823
+
824
+ | Element | Height |
825
+ | --------------------------------------------------- | --------- |
826
+ | Small button / chip / compact control | **28 px** |
827
+ | Default button, input, select | **36 px** |
828
+ | Large button (a page's main action) | **44 px** |
829
+ | Compact list row (one line) | **32 px** |
830
+ | Standard list row (title + caption) | **44 px** |
831
+ | List row with a ≥ 20 px glyph, avatar, or thumbnail | **56 px** |
832
+ | Table header row | **32 px** |
833
+ | Table body row | **36 px** |
834
+ | Your own content toolbar | **48 px** |
835
+ | Your own content header strip | **56 px** |
836
+
837
+ Additional rules:
838
+
839
+ - A vertical list of rows uses `gap: 0` with hairline separators, or `gap: 8px`
840
+ with bordered rows. Not both.
841
+ - Never exceed 24 px of vertical space between a heading and its content.
842
+ - Never centre a whole page's content vertically, except an empty state (§11.1).
843
+ - Never use a hero band, a full-bleed illustration, or a decorative gradient.
844
+ - Icons in rows and buttons are **16 px**; in a page title, **20 px**; in an empty
845
+ state, **32 px**. Nothing else.
846
+
847
+ ---
848
+
849
+ ## 9. Components
850
+
851
+ Build each of these from plain HTML plus the variables. Do not add a component
852
+ library, a CSS framework, or a design-token package.
853
+
854
+ ### 9.1 Buttons
855
+
856
+ Three variants, plus one destructive modifier of Secondary:
857
+
858
+ | Variant | Fill | Text | Border | When |
859
+ | --------- | ---------------------------- | ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------ |
860
+ | Primary | `--color-background-inverse` | `--color-text-inverse` | none | One per view, the main action |
861
+ | Secondary | `--color-background-primary` | `--color-text-primary` | `1px solid --color-border-primary` | Everything else |
862
+ | Ghost | `transparent` | `--color-text-secondary` | none | Toolbar and row-level actions |
863
+ | Danger | `transparent` | `--color-text-danger` | `1px solid --color-border-danger` | Only the confirming button of a destructive action, and never more than one per view |
864
+
865
+ A destructive action is never the Primary variant — a black filled button is the
866
+ safe default action, and the delete must not be the thing the eye lands on first.
867
+
868
+ ```css
869
+ .btn {
870
+ display: inline-flex;
871
+ align-items: center;
872
+ justify-content: center;
873
+ gap: 6px;
874
+ height: 36px;
875
+ padding: 0 14px;
876
+ flex: none;
877
+ font: inherit;
878
+ font-size: 14px;
879
+ line-height: 20px;
880
+ font-weight: 500;
881
+ border: 1px solid transparent;
882
+ border-radius: var(--app-radius-control);
883
+ background: transparent;
884
+ color: var(--app-text);
885
+ cursor: pointer;
886
+ user-select: none;
887
+ transition:
888
+ background-color 120ms ease,
889
+ border-color 120ms ease;
890
+ }
891
+ .btn:disabled {
892
+ cursor: default;
893
+ background: var(--color-background-disabled);
894
+ color: var(--color-text-disabled);
895
+ border-color: transparent;
896
+ }
897
+ .btn:focus-visible {
898
+ outline: 2px solid var(--app-accent);
899
+ outline-offset: 2px;
900
+ }
901
+
902
+ .btn-primary {
903
+ background: var(--color-background-inverse);
904
+ color: var(--color-text-inverse);
905
+ }
906
+ .btn-secondary {
907
+ background: var(--app-surface);
908
+ border-color: var(--app-border);
909
+ }
910
+ .btn-secondary:hover:not(:disabled) {
911
+ background: var(--color-background-ghost);
912
+ }
913
+ .btn-ghost {
914
+ color: var(--app-muted);
915
+ padding: 0 10px;
916
+ }
917
+ .btn-ghost:hover:not(:disabled) {
918
+ background: var(--color-background-ghost);
919
+ color: var(--app-text);
920
+ }
921
+ .btn-danger {
922
+ background: transparent;
923
+ color: var(--color-text-danger);
924
+ border-color: var(--color-border-danger);
925
+ }
926
+ ```
927
+
928
+ - Small button: `height: 28px; padding: 0 10px; font-size: 12px;`.
929
+ - Large button: `height: 44px; padding: 0 20px; font-size: 15px;`.
930
+ - An icon-only button is square: 28×28 or 36×36, and **must** have `aria-label`.
931
+ - Button labels are sentence case, verb first: "Add item", not "ADD ITEM" or
932
+ "Add Item".
933
+ - Never a gradient fill, never a coloured shadow, never a full-width button
934
+ unless the container is narrower than 320 px.
935
+
936
+ ### 9.2 Inputs and forms
937
+
938
+ ```css
939
+ .field {
940
+ display: flex;
941
+ flex-direction: column;
942
+ gap: 4px;
943
+ }
944
+ .field-label {
945
+ font-size: 12px;
946
+ line-height: 18px;
947
+ font-weight: 500;
948
+ color: var(--app-muted);
949
+ }
950
+ .input {
951
+ height: 36px;
952
+ padding: 0 10px;
953
+ width: 100%;
954
+ font: inherit;
955
+ font-size: 14px;
956
+ line-height: 20px;
957
+ color: var(--happy-input-text, var(--app-text));
958
+ background: var(--happy-input-background, var(--app-raised));
959
+ border: 1px solid var(--app-border);
960
+ border-radius: var(--app-radius-control);
961
+ }
962
+ .input::placeholder {
963
+ color: var(--happy-input-placeholder, var(--app-muted));
964
+ }
965
+ /* A field keeps its border when focused and draws the ring just inside its own
966
+ edge, so there is no 3px halo gap between the two. Buttons, which have no
967
+ filled edge to preserve, use the outward 2px offset instead. */
968
+ .input:focus-visible {
969
+ outline: 2px solid var(--app-accent);
970
+ outline-offset: -1px;
971
+ }
972
+ .input[aria-invalid="true"] {
973
+ border-color: var(--color-border-danger);
974
+ }
975
+ textarea.input {
976
+ height: auto;
977
+ min-height: 72px;
978
+ padding: 8px 10px;
979
+ resize: vertical;
980
+ }
981
+ ```
982
+
983
+ - Labels go **above** the field, 6 px away. Never use a placeholder as the label.
984
+ - **Exception, and only this one:** a search or filter field inside a 48 px
985
+ toolbar has no room for a label above it, so its name is given by
986
+ `aria-label` and its placeholder shows the same words. This is the single
987
+ place a placeholder-only field is permitted; every field in a form body has a
988
+ visible label.
989
+ - A form is a flex column with `gap: 16px` between fields.
990
+ - Error text sits 4 px under the field, 12/18 px, `--color-text-danger`, and is
991
+ referenced by `aria-describedby`.
992
+ - Actions go in a row at the end: `justify-content: flex-end; gap: 8px`, primary
993
+ last (rightmost).
994
+ - Never use a native `<input type="color">`, `<input type="range">` styling hack,
995
+ or a custom checkbox that loses keyboard behaviour. Style the native control
996
+ with `accent-color: var(--app-accent)`.
997
+
998
+ ### 9.3 Lists
999
+
1000
+ The standard row: a leading glyph or nothing, a shrinking main column, a fixed
1001
+ trailing column.
1002
+
1003
+ ```css
1004
+ .list {
1005
+ display: flex;
1006
+ flex-direction: column;
1007
+ }
1008
+ .row {
1009
+ display: flex;
1010
+ align-items: center;
1011
+ gap: 12px;
1012
+ min-height: 44px;
1013
+ padding: 8px 12px;
1014
+ border-bottom: 1px solid var(--app-border);
1015
+ background: var(--app-surface);
1016
+ }
1017
+ .list > .row:last-child {
1018
+ border-bottom: none;
1019
+ }
1020
+ .row:hover {
1021
+ background: var(--color-background-ghost);
1022
+ }
1023
+ .row[aria-selected="true"] {
1024
+ background: var(--happy-selected-background, var(--color-background-tertiary));
1025
+ }
1026
+ .row-main {
1027
+ display: flex;
1028
+ flex-direction: column;
1029
+ /* No gap: the 20 px and 16 px line boxes already separate the two lines, and
1030
+ a two-line row measures 52 px. */
1031
+ flex: 1 1 auto;
1032
+ min-width: 0;
1033
+ }
1034
+ .row-title {
1035
+ font-size: 14px;
1036
+ line-height: 20px;
1037
+ font-weight: 500;
1038
+ overflow: hidden;
1039
+ text-overflow: ellipsis;
1040
+ white-space: nowrap;
1041
+ }
1042
+ .row-meta {
1043
+ font-size: 12px;
1044
+ line-height: 18px;
1045
+ color: var(--app-muted);
1046
+ }
1047
+ .row-aside {
1048
+ display: flex;
1049
+ align-items: center;
1050
+ gap: 8px;
1051
+ flex: none;
1052
+ }
1053
+ ```
1054
+
1055
+ - A whole row that is clickable must be a `<button>` or an `<a>`, or carry
1056
+ `role="button"` **and** `tabindex="0"` **and** an Enter/Space handler.
1057
+ - Do not nest an interactive control inside a clickable row without stopping
1058
+ propagation; better, make the row non-clickable and give it an explicit action.
1059
+ - Reorderable lists need stable keys; never key by array index.
1060
+ - A list that can hold thousands of entries must be virtualised.
1061
+
1062
+ ### 9.4 Tables
1063
+
1064
+ A table is the one legitimate place for CSS Grid, or for a real `<table>`.
1065
+ Prefer `<table>` — you get semantics and keyboard behaviour for free.
1066
+
1067
+ ```css
1068
+ .table {
1069
+ width: 100%;
1070
+ border-collapse: collapse;
1071
+ font-size: 14px;
1072
+ line-height: 20px;
1073
+ }
1074
+ .table thead th {
1075
+ height: 32px;
1076
+ padding: 0 12px;
1077
+ text-align: left;
1078
+ font-size: 11px;
1079
+ line-height: 16px;
1080
+ font-weight: 500;
1081
+ color: var(--color-text-tertiary);
1082
+ background: var(--app-raised);
1083
+ border-bottom: 1px solid var(--app-border);
1084
+ }
1085
+ .table tbody td {
1086
+ height: 36px;
1087
+ padding: 0 12px;
1088
+ border-bottom: 1px solid var(--app-border);
1089
+ }
1090
+ .table tbody tr:hover td {
1091
+ background: var(--color-background-ghost);
1092
+ }
1093
+ .table td.num {
1094
+ text-align: right;
1095
+ font-variant-numeric: tabular-nums;
1096
+ /* A truncated number is a wrong number. Numeric cells never ellipsize; give
1097
+ the column enough width instead, or drop it entirely (§4.5). */
1098
+ white-space: nowrap;
1099
+ }
1100
+ /* An action cell holds a control, not text, so it is sized to that control and
1101
+ drops the text padding: 28px button + 8px of breathing room on each side. */
1102
+ .table .actions-column {
1103
+ width: 44px;
1104
+ }
1105
+ .table td.actions {
1106
+ padding: 0 8px;
1107
+ text-align: right;
1108
+ }
1109
+ ```
1110
+
1111
+ - Never zebra-stripe. A hairline between rows is enough.
1112
+ - Column heads are Micro type (11/16, weight 500, tertiary), left aligned, except
1113
+ numeric columns which are right aligned along with their cells.
1114
+ - A sortable head is a `<button>` inside the `<th>` and sets `aria-sort`.
1115
+ - Below 640 px, hide non-essential columns; do not turn rows into cards. Never
1116
+ hide the numeric column by squeezing it — hide it outright or keep it whole.
1117
+ - **Size every fixed column to its content, including its own padding.** With
1118
+ `table-layout: fixed` a declared width that is narrower than the cell's content
1119
+ plus its padding does not grow the column — the content spills past the table's
1120
+ edge and is clipped by whatever contains it, which is easy to miss because the
1121
+ document itself never reports an overflow. An icon-button column is 44 px wide
1122
+ _and_ uses the 8 px padding above; the two numbers go together.
1123
+ - A table inside a card with a radius needs `overflow: hidden` on the card, so the
1124
+ first header cell and last row cell are clipped to the curve. The header's own
1125
+ corners then need no radius of their own.
1126
+ - **A sticky header is optional, and only works in one arrangement.**
1127
+ `position: sticky` on `thead th` sticks to the nearest scrolling ancestor, so it
1128
+ is inert when the page scrollport is further up the tree, and any
1129
+ `overflow: hidden` or `overflow-x: auto` between the two — including the card
1130
+ clip above — becomes that ancestor and breaks it. Choose one:
1131
+
1132
+ - **Simplest, and the default:** no sticky header. The table scrolls with the
1133
+ page. This is correct and is what most plugin apps should do.
1134
+ - **Sticky:** the table's own container owns the vertical scroll
1135
+ (`overflow-y: auto` with a bounded height) and there is no clipping ancestor
1136
+ between it and the `thead`. Then add `position: sticky; top: 0; z-index: 1`
1137
+ and a solid `background` on the header cells. Do not also put the table in a
1138
+ rounded, clipped card, and remember §4.4: this container is now the view's one
1139
+ vertical scrollport, so the page around it must not scroll too.
1140
+
1141
+ ### 9.5 Cards
1142
+
1143
+ ```css
1144
+ .card {
1145
+ display: flex;
1146
+ flex-direction: column;
1147
+ gap: 12px;
1148
+ padding: 16px;
1149
+ background: var(--app-surface);
1150
+ border: 1px solid var(--app-border);
1151
+ border-radius: var(--app-radius-card);
1152
+ }
1153
+ .card-title {
1154
+ font-size: 15px;
1155
+ line-height: 20px;
1156
+ font-weight: 600;
1157
+ }
1158
+ ```
1159
+
1160
+ A card grid is a flex row with `flex-wrap: wrap; gap: 12px` and children at
1161
+ `flex: 1 1 280px`, or a real Grid with `repeat(auto-fill, minmax(280px, 1fr))`
1162
+ (comment the Grid, §4.1).
1163
+
1164
+ ### 9.6 Your own navigation, toolbars, and tabs
1165
+
1166
+ Permitted, for navigating **your** content:
1167
+
1168
+ ```css
1169
+ .toolbar {
1170
+ display: flex;
1171
+ align-items: center;
1172
+ gap: 8px;
1173
+ height: 48px;
1174
+ flex: none;
1175
+ /* Matches the page gutter (§2.5), so the toolbar's contents line up with the
1176
+ content below it. Raise both to 24px together on a wide page. */
1177
+ padding: 0 16px;
1178
+ border-bottom: 1px solid var(--app-border);
1179
+ background: var(--app-surface);
1180
+ }
1181
+ .tabs {
1182
+ display: flex;
1183
+ align-items: center;
1184
+ gap: 4px;
1185
+ }
1186
+ .tab {
1187
+ height: 28px;
1188
+ padding: 0 10px;
1189
+ border-radius: var(--app-radius-control);
1190
+ font-size: 13px;
1191
+ line-height: 18px;
1192
+ font-weight: 500;
1193
+ color: var(--app-muted);
1194
+ background: transparent;
1195
+ border: none;
1196
+ cursor: pointer;
1197
+ }
1198
+ .tab[aria-selected="true"] {
1199
+ color: var(--app-text);
1200
+ background: var(--happy-selected-background, var(--color-background-tertiary));
1201
+ }
1202
+ .tab:focus-visible {
1203
+ outline: 2px solid var(--app-accent);
1204
+ outline-offset: 2px;
1205
+ }
1206
+ ```
1207
+
1208
+ - Tabs use `role="tablist"` / `role="tab"` / `role="tabpanel"` and arrow-key
1209
+ navigation.
1210
+ - A tab strip is horizontal only. No vertical tab rail — that reads as Happy
1211
+ chrome (§1.4).
1212
+ - Breadcrumbs: 12/18 px, `--color-text-secondary`, separated by a `/` in
1213
+ `--color-text-tertiary`, last crumb `--color-text-primary`.
1214
+
1215
+ ### 9.7 Status: badges, chips, and indicators
1216
+
1217
+ ```css
1218
+ .badge {
1219
+ display: inline-flex;
1220
+ align-items: center;
1221
+ gap: 4px;
1222
+ flex: none;
1223
+ height: 20px;
1224
+ padding: 0 8px;
1225
+ font-size: 11px;
1226
+ line-height: 16px;
1227
+ font-weight: 500;
1228
+ border-radius: var(--border-radius-full, 999px);
1229
+ background: var(--color-background-secondary);
1230
+ color: var(--color-text-secondary);
1231
+ }
1232
+ .badge-info {
1233
+ background: var(--color-background-info);
1234
+ color: var(--color-text-info);
1235
+ }
1236
+ .badge-success {
1237
+ background: var(--color-background-success);
1238
+ color: var(--color-text-success);
1239
+ }
1240
+ .badge-warning {
1241
+ background: var(--color-background-warning);
1242
+ color: var(--color-text-warning);
1243
+ }
1244
+ .badge-danger {
1245
+ background: var(--color-background-danger);
1246
+ color: var(--color-text-danger);
1247
+ }
1248
+ ```
1249
+
1250
+ Semantics are fixed: **green = succeeded / healthy / connected**, **orange =
1251
+ needs attention / degraded / unsaved**, **red = failed / destructive / offline**,
1252
+ **blue = in progress / informational**, **neutral grey = idle / unknown /
1253
+ disabled**. Never use a semantic colour decoratively, and never re-map one.
1254
+
1255
+ A status must be legible without colour: pair the fill with a word ("Failed") or
1256
+ a text glyph. Do not use a bare coloured dot as the only signal.
1257
+
1258
+ ### 9.8 Menus and dialogs you own
1259
+
1260
+ **When to own one.** An overflow menu on a row, a filter popover, a confirm
1261
+ prompt for a destructive action: these belong inside your document. A whole view
1262
+ that should _be_ a dialog is different — ask Happy to present it (surface 3) via
1263
+ `app.requestDisplayMode`, and never fake it by covering your own viewport with a
1264
+ scrim.
1265
+
1266
+ **Menu / popover.** Anchored to its trigger, never wider than 280 px:
1267
+
1268
+ ```css
1269
+ .menu {
1270
+ position: absolute;
1271
+ z-index: 10;
1272
+ min-width: 180px;
1273
+ max-width: 280px;
1274
+ display: flex;
1275
+ flex-direction: column;
1276
+ padding: 4px;
1277
+ background: var(--app-surface);
1278
+ border: 1px solid var(--app-border);
1279
+ border-radius: var(--app-radius-block);
1280
+ box-shadow: var(--shadow-md, 0 4px 12px rgb(0 0 0 / 0.24));
1281
+ }
1282
+ .menu-item {
1283
+ display: flex;
1284
+ align-items: center;
1285
+ gap: 8px;
1286
+ height: 28px;
1287
+ padding: 0 8px;
1288
+ flex: none;
1289
+ font: inherit;
1290
+ font-size: 13px;
1291
+ line-height: 18px;
1292
+ text-align: left;
1293
+ background: none;
1294
+ border: 0;
1295
+ border-radius: var(--app-radius-control);
1296
+ color: var(--app-text);
1297
+ cursor: pointer;
1298
+ }
1299
+ .menu-item:hover {
1300
+ background: var(--app-ghost);
1301
+ }
1302
+ .menu-item[data-destructive] {
1303
+ color: var(--app-danger);
1304
+ }
1305
+ .menu-separator {
1306
+ height: 1px;
1307
+ margin: 4px 0;
1308
+ background: var(--app-border);
1309
+ flex: none;
1310
+ }
1311
+ ```
1312
+
1313
+ The trigger is a `<button>` with `aria-haspopup="menu"` and `aria-expanded`. The
1314
+ menu is `role="menu"`, its items `role="menuitem"`. Arrow keys move, Enter
1315
+ activates, Escape closes and returns focus to the trigger, and a click anywhere
1316
+ else closes it.
1317
+
1318
+ **Where to put it.** `position: absolute` inside a `position: relative` parent is
1319
+ correct _only_ when no ancestor clips or scrolls. A menu opened from a table row
1320
+ or a list inside a scrollport is the common case, and there the absolute menu is
1321
+ clipped by the scrollport and drifts out of alignment as the user scrolls. In that
1322
+ case anchor it to the viewport instead:
1323
+
1324
+ - `position: fixed`, with `top`/`left` computed from the trigger's
1325
+ `getBoundingClientRect()` at open time.
1326
+ - Flip above the trigger when it would extend past the bottom edge.
1327
+ - Close it on scroll and on window resize rather than trying to follow the
1328
+ trigger.
1329
+
1330
+ This is the one place a plugin app reads geometry from the DOM, and it is
1331
+ legitimate: there is no CSS expression of "anchored to that element but not
1332
+ clipped by its scroll container" that is portable today.
1333
+
1334
+ **Confirm dialog.** 360 px wide, centred over a scrim, for one destructive
1335
+ decision:
1336
+
1337
+ ```css
1338
+ .scrim {
1339
+ position: fixed;
1340
+ inset: 0;
1341
+ z-index: 20;
1342
+ display: flex;
1343
+ align-items: center;
1344
+ justify-content: center;
1345
+ padding: 24px;
1346
+ background: var(--happy-scrim, rgb(0 0 0 / 0.48));
1347
+ }
1348
+ .dialog {
1349
+ display: flex;
1350
+ flex-direction: column;
1351
+ gap: 16px;
1352
+ width: 360px;
1353
+ max-width: 100%;
1354
+ padding: 16px;
1355
+ background: var(--app-surface);
1356
+ border: 1px solid var(--app-border);
1357
+ /* 14px shell corner; the 1px border plus 16px padding puts every child
1358
+ outside the corner field, so no child needs an inner radius (§7.3). */
1359
+ border-radius: var(--border-radius-xl, 14px);
1360
+ box-shadow: var(--shadow-lg, 0 12px 32px rgb(0 0 0 / 0.45));
1361
+ }
1362
+ .dialog-title {
1363
+ font-size: 17px;
1364
+ line-height: 22px;
1365
+ font-weight: 600;
1366
+ margin: 0;
1367
+ }
1368
+ .dialog-body {
1369
+ font-size: 14px;
1370
+ line-height: 20px;
1371
+ color: var(--app-muted);
1372
+ margin: 0;
1373
+ }
1374
+ .dialog-actions {
1375
+ display: flex;
1376
+ justify-content: flex-end;
1377
+ gap: 8px;
1378
+ }
1379
+ ```
1380
+
1381
+ Use `<dialog>` if you can — you get the focus trap, Escape, and the accessible
1382
+ role for free. Otherwise: `role="dialog"`, `aria-modal="true"`,
1383
+ `aria-labelledby` pointing at the title, focus moved into the dialog on open and
1384
+ back to the trigger on close, Escape cancels, and a scrim click cancels unless
1385
+ the dialog holds unsaved input. The confirming button is the Danger variant
1386
+ (§9.1) and sits rightmost; Cancel is Secondary and sits to its left.
1387
+
1388
+ One dialog at a time. Never stack two, and never open one from inside a menu
1389
+ without closing the menu first.
1390
+
1391
+ ### 9.9 Links
1392
+
1393
+ ```css
1394
+ a {
1395
+ color: var(--happy-link, var(--app-accent));
1396
+ text-decoration: none;
1397
+ }
1398
+ a:hover {
1399
+ text-decoration: underline;
1400
+ }
1401
+ a:focus-visible {
1402
+ outline: 2px solid var(--app-accent);
1403
+ outline-offset: 2px;
1404
+ border-radius: 2px;
1405
+ }
1406
+ ```
1407
+
1408
+ An external link must be opened through the host, not by the iframe — and only
1409
+ when the host offers it. `openLinks` is an optional host capability: the message
1410
+ and page hosts advertise it, the standalone Happy Agent plugin page does not. Feature
1411
+ detect it, and when it is absent render the URL as selectable text rather than a
1412
+ link that does nothing:
1413
+
1414
+ ```tsx
1415
+ const canOpenLinks = app?.getHostCapabilities()?.openLinks !== undefined;
1416
+
1417
+ return canOpenLinks ? (
1418
+ <a
1419
+ href={url}
1420
+ onClick={(event) => {
1421
+ event.preventDefault();
1422
+ void app!.openLink({ url });
1423
+ }}
1424
+ >
1425
+ {label}
1426
+ </a>
1427
+ ) : (
1428
+ <span className="mono wrap-anywhere">{url}</span>
1429
+ );
1430
+ ```
1431
+
1432
+ Never `window.open`, never `target="_blank"`, never `location.href = …`. A
1433
+ sandboxed frame cannot navigate the top level and the attempt fails silently.
1434
+
1435
+ ---
1436
+
1437
+ ## 10. Using the accent meaningfully
1438
+
1439
+ There is **one** accent: `--color-ring-primary` (system blue), plus
1440
+ `--happy-link` (Happy teal) for hyperlink text.
1441
+
1442
+ Use the accent only for:
1443
+
1444
+ 1. The focus ring on every focusable element.
1445
+ 2. The selected state of a **form control** — a radio, checkbox, or switch — which
1446
+ means setting `accent-color: var(--color-ring-primary)` and letting the native
1447
+ control paint itself.
1448
+ 3. An in-progress indicator, and the `--color-text-info` badge (§9.7).
1449
+ 4. `--color-text-info` for informational text and icons.
1450
+
1451
+ A **selected row or tab is neutral**, not accent: it takes
1452
+ `--happy-selected-background` and normal text (§9.3, §9.6). This is deliberate —
1453
+ a table with six accent-filled rows is unreadable, and the neutral fill is what
1454
+ Happy itself uses. Do not add an accent underline, left rule, or text colour to a
1455
+ selected row or tab on top of the neutral fill.
1456
+
1457
+ That list is exhaustive. Do **not** use the accent for: page or section headings,
1458
+ body text, card borders, row hover, the primary button (that is black —
1459
+ `--color-background-inverse`), any filled area larger than a chip, or decoration.
1460
+
1461
+ One deliberate difference to be aware of: Happy's own chrome uses teal
1462
+ (`--happy-link`) for its general interactive colour, while a plugin app's
1463
+ selection and focus read system blue. That is intentional — the blue is the
1464
+ platform's focus colour and the standard MCP Apps `ring-primary` — but it means
1465
+ you should use teal, not blue, for anything of yours that will sit directly
1466
+ beside Happy's chrome: hyperlink text (§9.8) and the loading arc (§11.2).
1467
+
1468
+ ---
1469
+
1470
+ ## 11. States
1471
+
1472
+ Every view that loads data has four states, and you must implement all four.
1473
+
1474
+ ### 11.1 Empty
1475
+
1476
+ Centred in the available space, at most 320 px wide, and — as the one exception
1477
+ to §6.2's rule against centred text — centre aligned:
1478
+
1479
+ ```css
1480
+ .empty {
1481
+ display: flex;
1482
+ flex-direction: column;
1483
+ align-items: center;
1484
+ justify-content: center;
1485
+ gap: 8px;
1486
+ flex: 1 1 auto;
1487
+ padding: 32px;
1488
+ max-width: 320px;
1489
+ margin-inline: auto;
1490
+ text-align: center;
1491
+ }
1492
+ .empty-glyph {
1493
+ font-size: 32px;
1494
+ line-height: 32px;
1495
+ color: var(--app-faint);
1496
+ }
1497
+ .empty-title {
1498
+ font-size: 15px;
1499
+ line-height: 20px;
1500
+ font-weight: 600;
1501
+ color: var(--app-text);
1502
+ }
1503
+ .empty-body {
1504
+ font-size: 13px;
1505
+ line-height: 18px;
1506
+ color: var(--app-muted);
1507
+ }
1508
+ ```
1509
+
1510
+ - The glyph is optional; nothing is an acceptable choice.
1511
+ - The title is one short sentence saying what is missing.
1512
+ - The body is one optional sentence saying how to fix it.
1513
+ - One action, if there is an obvious one, in the Secondary variant.
1514
+
1515
+ Never illustrate an empty state with a large graphic, and never apologise
1516
+ ("Oops!", "Nothing to see here!").
1517
+
1518
+ ### 11.2 Loading
1519
+
1520
+ - **First load, no data yet:** a centred 18 px ring with a single arc, plus a
1521
+ 13/18 px `--color-text-secondary` line ("Loading items…"). Nothing else. This is
1522
+ the canonical spinner — use it verbatim:
1523
+
1524
+ ```css
1525
+ .spinner {
1526
+ flex: none;
1527
+ width: 18px;
1528
+ height: 18px;
1529
+ border: 2px solid var(--app-border);
1530
+ border-top-color: var(--app-link);
1531
+ border-radius: var(--app-radius-pill);
1532
+ animation: spinner-rotate 900ms linear infinite;
1533
+ }
1534
+ @keyframes spinner-rotate {
1535
+ to {
1536
+ transform: rotate(360deg);
1537
+ }
1538
+ }
1539
+ ```
1540
+
1541
+ The arc is teal (`--happy-link`), not blue, so it matches the ring Happy draws
1542
+ in its own loading state one hairline above yours. Under
1543
+ `prefers-reduced-motion` the animation stops and the ring becomes a static
1544
+ arc — which is why the adjacent "Loading…" text is required, not optional: it
1545
+ is what carries the meaning when nothing moves.
1546
+
1547
+ - **Refreshing existing data:** keep the data on screen. Show a 2 px indeterminate
1548
+ bar at the top of the content region, or dim the affected rows to
1549
+ `opacity: 0.6`. Never blank a populated view.
1550
+ - **Optional:** skeleton blocks — `--color-background-secondary`, the exact height
1551
+ and radius of the real content. If you animate them, respect §13.
1552
+ - Never a full-screen spinner overlay in a plugin app. Never a progress bar that
1553
+ fakes progress.
1554
+
1555
+ ### 11.3 Error
1556
+
1557
+ An inline block at the top of the content, not a modal, not a toast:
1558
+
1559
+ ```css
1560
+ .error {
1561
+ display: flex;
1562
+ flex-direction: column;
1563
+ gap: 8px;
1564
+ padding: 12px;
1565
+ border-radius: var(--border-radius-md, 8px);
1566
+ background: var(--color-background-danger);
1567
+ border: 1px solid var(--color-border-danger);
1568
+ color: var(--color-text-primary);
1569
+ }
1570
+ .error-title {
1571
+ font-size: 14px;
1572
+ line-height: 20px;
1573
+ font-weight: 600;
1574
+ color: var(--color-text-danger);
1575
+ }
1576
+ ```
1577
+
1578
+ - Say what failed and what the user can do. Show the underlying message in
1579
+ `--font-mono` 12/18 px only if it is actionable.
1580
+ - Offer a retry **action** if retrying can help. This is not the forbidden
1581
+ "Refresh" button — it is recovery from a specific failure.
1582
+ - Never `alert()`, never `console.error` as the only surface.
1583
+
1584
+ ### 11.4 Disabled and read-only
1585
+
1586
+ - Disabled: `--color-background-disabled`, `--color-text-disabled`, no hover,
1587
+ `cursor: default`, and the real `disabled` attribute so it leaves the tab order.
1588
+ - Read-only content: normal colours, no control affordance. Do not render a
1589
+ disabled input to show a value — render text.
1590
+ - Never disable a control without an adjacent explanation of why.
1591
+
1592
+ ### 11.5 Staying current
1593
+
1594
+ Your app must keep itself up to date. When Happy tells you data changed — a new
1595
+ tool result, an instance `dataRevision` bump — re-read and re-render without a
1596
+ remount and without the user asking. Do not ship a "Refresh" button whose only
1597
+ job is to re-fetch.
1598
+
1599
+ ### 11.6 Connecting, and failing to connect
1600
+
1601
+ Before the four data states there is a fifth: the handshake (§1.1). `useHappyApp`
1602
+ gives you `isConnected` and `error`, and you must render something sensible for
1603
+ every combination of them. This is not optional — an unhandled handshake shows the
1604
+ user a blank document.
1605
+
1606
+ ```tsx
1607
+ if (error) return <ErrorBlock title="This app could not start." detail={error.message} />;
1608
+ if (!isConnected) return <Loading label="Starting…" />;
1609
+ if (items === undefined) return <Loading label="Loading items…" />;
1610
+ if (items.length === 0) return <Empty />;
1611
+ return <List items={items} />;
1612
+ ```
1613
+
1614
+ - While `!isConnected`, render the §11.2 first-load state. Do not render your
1615
+ populated layout with placeholder data, and do not render nothing.
1616
+ - When `error` is set the app never connected: there is no host to retry through
1617
+ and no tool you can call. Render the §11.3 block with no retry action, and say
1618
+ that the app could not start rather than that data could not load.
1619
+ - Everything in these two states is painted from your §5.4 fallbacks, because no
1620
+ host variable has arrived yet. Check that they are legible.
1621
+
1622
+ ---
1623
+
1624
+ ## 12. Focus, keyboard, and accessibility
1625
+
1626
+ - **Every** interactive element must show a focus ring:
1627
+ `outline: 2px solid var(--color-ring-primary); outline-offset: 2px;` under
1628
+ `:focus-visible`. Never `outline: none` without an equally visible replacement.
1629
+ - Never set a positive `tabindex`. DOM order is tab order; make DOM order match
1630
+ visual order.
1631
+ - Use real elements: `<button>`, `<a href>`, `<input>`, `<select>`, `<table>`,
1632
+ `<ul>`. A `<div onclick>` is a defect.
1633
+ - Give every icon-only control an `aria-label`. Mark decorative glyphs
1634
+ `aria-hidden="true"`.
1635
+ - One `<h1>` per document (your content's title, not the app name Happy already
1636
+ shows), then `<h2>`/`<h3>` in order, never skipping a level.
1637
+ - Keyboard contracts you must honour: Enter and Space activate a button; Escape
1638
+ closes a popover or cancels an inline edit; arrow keys move within a tablist,
1639
+ menu, or grid; Tab never enters a closed disclosure.
1640
+ - Announce asynchronous changes with `aria-live="polite"` on the status region.
1641
+ - Respect `prefers-reduced-motion` (§13).
1642
+ - Do not trap focus unless you have opened a modal you own, and then return focus
1643
+ to the trigger on close.
1644
+ - Do not autofocus on load in an inline card; the user did not ask you to steal
1645
+ the caret from the chat composer. Autofocus is acceptable in a modal or a page
1646
+ whose sole purpose is that field.
1647
+
1648
+ ---
1649
+
1650
+ ## 13. Motion and performance
1651
+
1652
+ Happy's surfaces are still. Motion is for state feedback, never for arrival.
1653
+
1654
+ Permitted:
1655
+
1656
+ | Change | Duration | Easing |
1657
+ | --------------------------- | -------- | ---------- |
1658
+ | Hover / press colour | 120 ms | `ease` |
1659
+ | Expand / collapse a section | 160 ms | `ease-out` |
1660
+ | Popover appear | 120 ms | `ease-out` |
1661
+ | Indeterminate progress | loop | linear |
1662
+
1663
+ Forbidden: entrance animations on page or list load, parallax, animated
1664
+ gradients, bouncing, spinning icons that are not progress, anything over 200 ms,
1665
+ and animating `width`, `height`, `top`, or `left`. Animate only `opacity` and
1666
+ `transform`.
1667
+
1668
+ ```css
1669
+ @media (prefers-reduced-motion: reduce) {
1670
+ *,
1671
+ *::before,
1672
+ *::after {
1673
+ animation-duration: 0.01ms !important;
1674
+ animation-iteration-count: 1 !important;
1675
+ transition-duration: 0.01ms !important;
1676
+ }
1677
+ }
1678
+ ```
1679
+
1680
+ Performance:
1681
+
1682
+ - The whole app is one HTML document. Keep it small; no framework beyond React,
1683
+ which the SDK already bundles.
1684
+ - No network requests to third-party origins for fonts, icons, analytics, or CSS.
1685
+ Data comes through MCP tool calls.
1686
+ - Virtualise any list that can exceed a few hundred rows.
1687
+ - Do not observe `resize` at high frequency; the SDK's auto-resize already reports
1688
+ your content height on animation frames.
1689
+ - Do not poll on a timer for data Happy pushes to you.
1690
+
1691
+ ---
1692
+
1693
+ ## 14. Icons
1694
+
1695
+ You have no access to Happy's icon fonts (Ionicons and Octicons), and there is no
1696
+ channel to get them. So:
1697
+
1698
+ 1. **Prefer no icon.** Most rows and buttons read better with a word. This is the
1699
+ default answer.
1700
+ 2. When an icon is genuinely needed, use one from the set below. Do not invent a
1701
+ different one, and do not mix drawing styles within an app.
1702
+
1703
+ These seven cover nearly every plugin app. Copy them verbatim; they share one
1704
+ grid, one stroke weight, and one cap style, which is what makes a set look like a
1705
+ set rather than seven unrelated drawings.
1706
+
1707
+ ```tsx
1708
+ /* All icons: 16x16 box, 1.5px stroke, round caps and joins, currentColor. */
1709
+ const icon = {
1710
+ width: 16,
1711
+ height: 16,
1712
+ viewBox: "0 0 16 16",
1713
+ fill: "none",
1714
+ stroke: "currentColor",
1715
+ strokeWidth: 1.5,
1716
+ strokeLinecap: "round" as const,
1717
+ strokeLinejoin: "round" as const,
1718
+ "aria-hidden": true,
1719
+ };
1720
+
1721
+ export const ChevronDown = () => (
1722
+ <svg {...icon}>
1723
+ <path d="M4 6.5 8 10.5l4-4" />
1724
+ </svg>
1725
+ );
1726
+ export const ChevronRight = () => (
1727
+ <svg {...icon}>
1728
+ <path d="M6.5 4 10.5 8l-4 4" />
1729
+ </svg>
1730
+ );
1731
+ export const Close = () => (
1732
+ <svg {...icon}>
1733
+ <path d="M4 4l8 8M12 4l-8 8" />
1734
+ </svg>
1735
+ );
1736
+ export const Plus = () => (
1737
+ <svg {...icon}>
1738
+ <path d="M8 3.5v9M3.5 8h9" />
1739
+ </svg>
1740
+ );
1741
+ export const Check = () => (
1742
+ <svg {...icon}>
1743
+ <path d="M3.5 8.5l3 3 6-7" />
1744
+ </svg>
1745
+ );
1746
+ export const Search = () => (
1747
+ <svg {...icon}>
1748
+ <circle cx="7" cy="7" r="3.75" />
1749
+ <path d="M10 10l2.5 2.5" />
1750
+ </svg>
1751
+ );
1752
+ export const More = () => (
1753
+ <svg {...icon} fill="currentColor" stroke="none">
1754
+ <circle cx="3.5" cy="8" r="1.25" />
1755
+ <circle cx="8" cy="8" r="1.25" />
1756
+ <circle cx="12.5" cy="8" r="1.25" />
1757
+ </svg>
1758
+ );
1759
+ ```
1760
+
1761
+ Rules:
1762
+
1763
+ - 16 px in rows, buttons, and menus. 20 px beside a page title. 32 px in an empty
1764
+ state — scale the same `viewBox`, never redraw at a new size.
1765
+ - `currentColor` only, so an icon follows its container's text colour and
1766
+ therefore the theme, with no extra work.
1767
+ - `aria-hidden="true"` on the SVG, and the meaning in the button's `aria-label` or
1768
+ in adjacent text.
1769
+ - Never load an icon font, an icon sprite, or an SVG over the network.
1770
+ - Never use an emoji as a control's icon.
1771
+ - A Unicode text glyph (`↑ ↓ ✓ ×`) is acceptable in a dense table cell where a
1772
+ full SVG would be noise. Wrap it the same way.
1773
+
1774
+ ---
1775
+
1776
+ ## 15. Writing
1777
+
1778
+ - Sentence case everywhere: labels, buttons, headings, menu items.
1779
+ - Verb-first actions: "Add item", "Delete list", "Retry".
1780
+ - No exclamation marks. No "Oops", "Whoops", "Uh-oh", "Awesome", or "Please".
1781
+ - State facts: "3 items", "Last updated 2 minutes ago", "Failed to load items".
1782
+ - Empty and error copy is one sentence of what, one sentence of what to do.
1783
+ - Never expose internal identifiers, stack traces, or tool names in user-facing
1784
+ copy unless they are the point of the view.
1785
+
1786
+ ---
1787
+
1788
+ ## 16. Anti-patterns
1789
+
1790
+ Each of these is a defect. They are listed because they are what a model
1791
+ generating a web page tends to do by default.
1792
+
1793
+ **Colour and theme**
1794
+
1795
+ 1. A hex, `rgb()`, `hsl()`, or named colour anywhere except inside a `var()`
1796
+ fallback. The keywords `currentColor`, `transparent`, `inherit`, and `none` are
1797
+ not colours in this sense and are always allowed — they are how the icon set
1798
+ (§14) and the ghost border follow the theme.
1799
+ 2. Tailwind-style palette names (`slate-800`, `blue-500`) or a bundled CSS
1800
+ framework.
1801
+ 3. A gradient of any kind — background, text, border, or button.
1802
+ 4. `@media (prefers-color-scheme)` to choose colours instead of the variables.
1803
+ 5. Reading a variable in JavaScript and storing the resolved colour.
1804
+ 6. Aliasing a host variable to itself (`--happy-canvas: var(--happy-canvas, …)`).
1805
+ 7. Repeating a fallback at every use site instead of once in `:root`.
1806
+ 8. A dark-mode-only or light-mode-only design.
1807
+
1808
+ **Layout**
1809
+
1810
+ 9. `float`, layout `<table>`, `inline-block` spacing, or absolute positioning for
1811
+ layout (§4.1).
1812
+ 10. Margins between siblings instead of the parent's `gap` (§4.2).
1813
+ 11. A flex child that can shrink without `min-width: 0` (§4.3).
1814
+ 12. Padding on the scrollport instead of on an inner wrapper (§4.4).
1815
+ 13. Two nested vertical scrollbars (§4.4).
1816
+ 14. `100vh` or `min-height: 100vh` anywhere. On a fullscreen surface it is merely
1817
+ redundant with `100%`. On an **inline** surface it is a ratchet: your content
1818
+ is then always at least as tall as the frame, so the height you report can
1819
+ never shrink and the card is stuck at its maximum forever.
1820
+ 15. `height: 100%` on `<html>`/`<body>` in an **inline** surface — same ratchet,
1821
+ and it reports 800 px from the first frame (§2.2).
1822
+ 16. Laying out from `containerDimensions` in JavaScript; it does not survive a
1823
+ window resize (§1.1).
1824
+ 17. Mobile breakpoints, a hamburger menu, or touch-sized targets (§2).
1825
+
1826
+ **Chrome and identity**
1827
+
1828
+ 18. A fake title bar, window controls, or a repeat of the app's title (§1.4).
1829
+ 19. A left navigation rail that imitates Happy's feature rail (§1.4).
1830
+ 20. A full-viewport scrim for something that is not a dialog you own (§9.8).
1831
+ 21. `window.open` or `target="_blank"` instead of a capability-checked
1832
+ `app.openLink` (§9.9).
1833
+ 22. Shipping a web font, an icon font, a Google Fonts `@import`, or a CDN
1834
+ stylesheet (§6.1, §14).
1835
+
1836
+ **Type and density**
1837
+
1838
+ 23. Text below 11 px, or a weight outside 400/500/600 (§6.2).
1839
+ 24. A 32 px hero heading, centred marketing copy, or an illustration band (§8).
1840
+ 25. Layout spacing off the 4 px scale (§3) — but note that control-internal
1841
+ padding is deliberately not on it.
1842
+ 26. Zebra-striped tables, coloured row backgrounds, or shadowed buttons (§7, §9.4).
1843
+ 27. Emoji as UI iconography, or a hand-drawn icon outside the §14 set.
1844
+
1845
+ **Behaviour**
1846
+
1847
+ 28. Rendering nothing, or a populated layout with placeholder data, while the
1848
+ handshake is still in flight (§11.6).
1849
+ 29. A "Refresh" button as the only way to get current data (§11.5).
1850
+ 30. A full-screen loading overlay, or blanking a populated view while refreshing.
1851
+ 31. `alert()`, `confirm()`, or `prompt()`.
1852
+ 32. Entrance animations, or any transition longer than 200 ms (§13).
1853
+ 33. `outline: none` without a replacement focus indicator (§12).
1854
+ 34. A `<div>` with an `onclick` and no role, tabindex, or key handler (§12).
1855
+
1856
+ ---
1857
+
1858
+ ## 17. A complete baseline
1859
+
1860
+ Copy this as the first stylesheet of a new plugin app. It implements §5.4, §3,
1861
+ §4, §6, and §12, and nothing else — every component style is yours to add from §9.
1862
+
1863
+ ```css
1864
+ /* ---- Host variable mapping and fallbacks (declare once) --------------- */
1865
+ :root {
1866
+ color-scheme: light dark;
1867
+
1868
+ --app-surface: var(--color-background-primary, light-dark(#ffffff, #212121));
1869
+ --app-raised: var(--color-background-secondary, light-dark(#f8f8f8, #171717));
1870
+ --app-inset: var(--color-background-tertiary, light-dark(#f0f0f2, #2c2c2e));
1871
+ --app-disabled: var(--color-background-disabled, light-dark(#f0f0f2, #2c2c2e));
1872
+ --app-ghost: var(
1873
+ --color-background-ghost,
1874
+ light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.08))
1875
+ );
1876
+ --app-canvas: var(--happy-canvas, light-dark(#f5f5f5, #1e1e1e));
1877
+ --app-code: var(--happy-code-background, light-dark(#f6f8fa, #161b22));
1878
+
1879
+ --app-text: var(--color-text-primary, light-dark(#000000, #ffffff));
1880
+ --app-muted: var(--color-text-secondary, light-dark(#49454f, #cac4d0));
1881
+ --app-faint: var(--color-text-tertiary, light-dark(#807d84, #97939c));
1882
+ --app-on-action: var(--color-text-inverse, #ffffff);
1883
+ --app-disabled-text: var(--color-text-disabled, light-dark(#adabb0, #6d6a70));
1884
+
1885
+ --app-border: var(--color-border-primary, light-dark(#eaeaea, #292929));
1886
+ --app-border-strong: var(--color-border-secondary, light-dark(#cccccc, #4d4d4d));
1887
+ --app-accent: var(--color-ring-primary, light-dark(#007aff, #0a84ff));
1888
+ --app-link: var(--happy-link, #2baccc);
1889
+ --app-action: var(--color-background-inverse, #000000);
1890
+
1891
+ --app-danger: var(--color-text-danger, light-dark(#f44336, #f48fb1));
1892
+ --app-success: var(--color-text-success, light-dark(#34c759, #32d74b));
1893
+ --app-warning: var(--color-text-warning, light-dark(#ff9500, #ffab00));
1894
+ --app-info: var(--color-text-info, light-dark(#007aff, #0a84ff));
1895
+
1896
+ /* The soft semantic fills. The host composes each from its own hue over the
1897
+ surface; these fallbacks are the same mixes written out. */
1898
+ --app-danger-soft: var(--color-background-danger, light-dark(#fff0f0, rgb(255 69 58 / 0.15)));
1899
+ --app-success-soft: var(
1900
+ --color-background-success,
1901
+ light-dark(rgb(52 199 89 / 0.14), rgb(50 215 75 / 0.14))
1902
+ );
1903
+ --app-warning-soft: var(
1904
+ --color-background-warning,
1905
+ light-dark(#fff8f0, rgb(255 159 10 / 0.15))
1906
+ );
1907
+ --app-info-soft: var(
1908
+ --color-background-info,
1909
+ light-dark(rgb(0 122 255 / 0.12), rgb(10 132 255 / 0.12))
1910
+ );
1911
+
1912
+ --app-radius-control: var(--border-radius-sm, 6px);
1913
+ --app-radius-block: var(--border-radius-md, 8px);
1914
+ --app-radius-card: var(--border-radius-lg, 10px);
1915
+ --app-radius-pill: var(--border-radius-full, 999px);
1916
+
1917
+ --app-font: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
1918
+ --app-mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1919
+ }
1920
+
1921
+ /* ---- Document ---------------------------------------------------------- */
1922
+ *,
1923
+ *::before,
1924
+ *::after {
1925
+ box-sizing: border-box;
1926
+ }
1927
+
1928
+ html,
1929
+ body {
1930
+ margin: 0;
1931
+ padding: 0;
1932
+ }
1933
+
1934
+ /* Include EXACTLY ONE of the following two blocks.
1935
+ *
1936
+ * FULLSCREEN (surfaces 2-5): the host owns the height, so fill the frame. */
1937
+ html,
1938
+ body,
1939
+ #root {
1940
+ height: 100%;
1941
+ }
1942
+
1943
+ /* INLINE (surface 1): you own the height, so include NOTHING here — no height,
1944
+ * no min-height, no vh — and the document sizes to its content, which is what
1945
+ * auto-resize reports to the host. */
1946
+
1947
+ body {
1948
+ font-family: var(--app-font);
1949
+ font-size: 14px;
1950
+ line-height: 20px;
1951
+ font-weight: 400;
1952
+ font-synthesis: none;
1953
+ color: var(--app-text);
1954
+ background: var(--app-surface);
1955
+ -webkit-font-smoothing: antialiased;
1956
+ }
1957
+
1958
+ #root {
1959
+ display: flex;
1960
+ flex-direction: column;
1961
+ min-height: 0;
1962
+ width: 100%;
1963
+ }
1964
+
1965
+ /* ---- Shared primitives -------------------------------------------------- */
1966
+ .row {
1967
+ display: flex;
1968
+ align-items: center;
1969
+ gap: 8px;
1970
+ }
1971
+ .column {
1972
+ display: flex;
1973
+ flex-direction: column;
1974
+ gap: 8px;
1975
+ }
1976
+ .grow {
1977
+ flex: 1 1 auto;
1978
+ min-width: 0;
1979
+ }
1980
+ .fixed {
1981
+ flex: none;
1982
+ }
1983
+ .truncate {
1984
+ overflow: hidden;
1985
+ text-overflow: ellipsis;
1986
+ white-space: nowrap;
1987
+ }
1988
+
1989
+ .scrollport {
1990
+ flex: 1 1 auto;
1991
+ min-height: 0;
1992
+ width: 100%;
1993
+ overflow-y: auto;
1994
+ overflow-x: hidden;
1995
+ scrollbar-width: thin;
1996
+ scrollbar-color: var(--happy-scrollbar-thumb, rgb(150 150 150 / 0.4)) transparent;
1997
+ }
1998
+ .scrollport-content {
1999
+ display: flex;
2000
+ flex-direction: column;
2001
+ gap: 12px;
2002
+ padding: 16px;
2003
+ }
2004
+
2005
+ code,
2006
+ pre,
2007
+ .mono {
2008
+ font-family: var(--app-mono);
2009
+ font-size: 12px;
2010
+ line-height: 18px;
2011
+ }
2012
+ pre {
2013
+ margin: 0;
2014
+ padding: 12px;
2015
+ /* Horizontal only: a long log line scrolls sideways, but the block grows to
2016
+ its content vertically, so it never becomes a second vertical scrollport
2017
+ (§4.4). Never add `max-height` with `overflow-y: auto` here. An unbounded
2018
+ log is laid out the other way round: the `pre` becomes the view's one
2019
+ scrollport itself — `flex: 1 1 auto; min-height: 0; overflow: auto` inside
2020
+ the body, with no other scrolling element — or it stays in normal flow and
2021
+ you cap the number of lines you render. */
2022
+ overflow-x: auto;
2023
+ background: var(--app-code);
2024
+ border-radius: var(--app-radius-block);
2025
+ }
2026
+
2027
+ a {
2028
+ color: var(--app-link);
2029
+ text-decoration: none;
2030
+ }
2031
+ a:hover {
2032
+ text-decoration: underline;
2033
+ }
2034
+
2035
+ .spinner {
2036
+ flex: none;
2037
+ width: 18px;
2038
+ height: 18px;
2039
+ border: 2px solid var(--app-border);
2040
+ border-top-color: var(--app-link);
2041
+ border-radius: var(--app-radius-pill);
2042
+ animation: spinner-rotate 900ms linear infinite;
2043
+ }
2044
+ @keyframes spinner-rotate {
2045
+ to {
2046
+ transform: rotate(360deg);
2047
+ }
2048
+ }
2049
+
2050
+ :focus-visible {
2051
+ outline: 2px solid var(--app-accent);
2052
+ outline-offset: 2px;
2053
+ }
2054
+
2055
+ @media (prefers-reduced-motion: reduce) {
2056
+ *,
2057
+ *::before,
2058
+ *::after {
2059
+ animation-duration: 0.01ms !important;
2060
+ animation-iteration-count: 1 !important;
2061
+ transition-duration: 0.01ms !important;
2062
+ }
2063
+ }
2064
+ ```
2065
+
2066
+ And the shape of a fullscreen page built on it:
2067
+
2068
+ ```tsx
2069
+ function Page() {
2070
+ const { hostContext, isConnected } = useHappyApp({
2071
+ appInfo: { name: "example", version: "1.0.0" },
2072
+ });
2073
+
2074
+ return (
2075
+ <div className="page">
2076
+ <div className="toolbar">
2077
+ <h1 className="page-title grow truncate">Deployments</h1>
2078
+ <button className="btn btn-secondary fixed" type="button">
2079
+ Filter
2080
+ </button>
2081
+ <button className="btn btn-primary fixed" type="button">
2082
+ New deployment
2083
+ </button>
2084
+ </div>
2085
+ <div className="scrollport">
2086
+ <div className="scrollport-content">{/* rows, cards, table */}</div>
2087
+ </div>
2088
+ </div>
2089
+ );
2090
+ }
2091
+ ```
2092
+
2093
+ ```css
2094
+ .page {
2095
+ display: flex;
2096
+ flex-direction: column;
2097
+ height: 100%;
2098
+ min-height: 0;
2099
+ background: var(--app-surface);
2100
+ }
2101
+ .page-title {
2102
+ font-size: 20px;
2103
+ line-height: 26px;
2104
+ font-weight: 600;
2105
+ letter-spacing: -0.01em;
2106
+ margin: 0;
2107
+ }
2108
+ ```
2109
+
2110
+ ---
2111
+
2112
+ ## 18. Acceptance checklist
2113
+
2114
+ Verify each item by measurement, not by looking. Where a number is given, read it
2115
+ out of `getComputedStyle` or the DevTools box model at **2× device pixel ratio**,
2116
+ in **both** light and dark, at **three** container widths: **400**, **640**, and
2117
+ **1180** CSS pixels.
2118
+
2119
+ **Theme**
2120
+
2121
+ - [ ] `document.documentElement.style` carries `--color-background-primary` and
2122
+ `--happy-canvas` once the app has connected.
2123
+ - [ ] Before it connects the app already renders legibly from the §5.4 fallbacks —
2124
+ no flash of unstyled or invisible content.
2125
+ - [ ] No rule is keyed on `[data-theme]`.
2126
+ - [ ] Toggling Happy between light and dark repaints the app with no reload, and
2127
+ every visible colour changes appropriately.
2128
+ - [ ] A grep of the app's CSS and TSX finds **zero** hex, `rgb(`, `hsl(`, or
2129
+ named colours outside `var(…, fallback)` positions.
2130
+ - [ ] Opened standalone in a browser tab with no host, the app is still legible
2131
+ in both OS appearances (the fallbacks work) and no variable is cyclic.
2132
+
2133
+ **Layout**
2134
+
2135
+ - [ ] `document.body` computed `margin` is `0px`.
2136
+ - [ ] On a fullscreen surface the outermost element fills the frame
2137
+ (`clientHeight === document.documentElement.clientHeight`) and there is no
2138
+ page-level scrollbar on `<body>`. Verify after resizing the host window, not
2139
+ only at first load.
2140
+ - [ ] On an inline surface the reported height equals the content height and is
2141
+ between 120 and 800.
2142
+ - [ ] The scrollport's computed `padding` and `margin` are both `0px`, and its
2143
+ box exactly matches its parent's content box.
2144
+ - [ ] Exactly one element in the view has a vertical scrollbar.
2145
+ - [ ] Every flex `gap`, every container padding, and every margin between elements
2146
+ is 0, 4, 8, 12, 16, 24, 32, or 48 px. (Padding _inside_ a control is exempt —
2147
+ it comes from §9 verbatim.)
2148
+ - [ ] No rule anywhere uses `vh` units.
2149
+ - [ ] At 400 px wide nothing overflows horizontally
2150
+ (`document.documentElement.scrollWidth <= clientWidth`).
2151
+ - [ ] A row with a 200-character title ellipsizes and its trailing controls stay
2152
+ at their declared width.
2153
+
2154
+ **Type**
2155
+
2156
+ - [ ] No computed `font-size` below `11px`.
2157
+ - [ ] Every text element's `font-family` resolves through `--font-sans` or
2158
+ `--font-mono`; no other family appears.
2159
+ - [ ] Every `font-weight` is 400, 500, or 600.
2160
+ - [ ] Each `font-size`/`line-height` pair matches a row of the §6.2 table.
2161
+
2162
+ **Shape**
2163
+
2164
+ - [ ] Buttons and inputs are exactly 28, 36, or 44 px tall.
2165
+ - [ ] Every `border-radius` is 0, 6, 8, 10, 14, or 999 px.
2166
+ - [ ] Every border is exactly 1 px, except the loading ring and focus rings,
2167
+ which are 2 px.
2168
+ - [ ] For each nested rounded corner, `inner === max(0, outer − inset)`.
2169
+ - [ ] No `box-shadow` on a button, input, row, chip, or table.
2170
+
2171
+ **Accent and contrast**
2172
+
2173
+ - [ ] The accent appears only in focus rings, form-control selection
2174
+ (`accent-color`), progress, and info text — never on a selected row or tab,
2175
+ which are neutral (§10).
2176
+ - [ ] Body text contrast is ≥ 4.5 : 1 against its background in both schemes.
2177
+ - [ ] Focus rings are ≥ 3 : 1 against the adjacent surface in both schemes.
2178
+
2179
+ **Interaction**
2180
+
2181
+ - [ ] Tabbing reaches every control in visual order and each shows a 2 px ring
2182
+ (offset 2 outward, or −1 inside a filled input), fully inside the scrollport.
2183
+ - [ ] Enter and Space activate every clickable thing; Escape closes every menu and
2184
+ dialog and returns focus to its trigger.
2185
+ - [ ] Every icon-only control has an `aria-label`.
2186
+ - [ ] All six states — connecting, connect-failed, loading, empty, error,
2187
+ populated — render correctly at each of the three widths.
2188
+ - [ ] No transition exceeds 200 ms; `prefers-reduced-motion` disables them all and
2189
+ the loading state still reads without motion.
2190
+ - [ ] There is no "Refresh" button, no `alert()`, no `window.open`, and every
2191
+ external link is capability-checked.