115Composer
The input. One hairline box that grows with the textarea rather than a fixed field with a scrollbar — a student pasting an Apex class needs to see it. Focus moves the ring to the whole composer, so the toolbar reads as part of the control it belongs to.
Use it for
- The assistant's input, docked at the foot of the thread
- The context pill for what is in scope — the lesson, the file, the org
- Attachments: Apex classes, flow screenshots, error logs
Not for
- A search field — a search box returns results, this one starts a conversation
- Hiding the Enter/Shift+Enter contract: the wrong guess posts half a question to a class
Default
markup
<form class="ns-aicomposer">
<label class="ns-visually-hidden" for="ai-doc-in">Ask the assistant</label>
<textarea class="ns-aicomposer__area" id="ai-doc-in" rows="2" placeholder="Ask about this lesson, paste your Apex, or ask for a project…"></textarea>
<div class="ns-aicomposer__bar">
<button type="button" class="ns-aimode" aria-haspopup="true" aria-expanded="false"><i class="ph ph-graduation-cap" aria-hidden="true"></i> Tutor <i class="ph ph-caret-down" aria-hidden="true"></i></button>
<button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Attach a file"><i class="ph ph-folder-open" aria-hidden="true"></i></button>
<button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Dictate"><i class="ph ph-microphone" aria-hidden="true"></i></button>
<span class="ns-aicomposer__spacer"></span>
<span class="ns-aicomposer__hint">Enter to send · Shift+Enter for a new line</span>
<button type="submit" class="ns-btn ns-btn--primary ns-btn--icon ns-btn--sm" aria-label="Send message"><i class="ph ph-paper-plane-tilt" aria-hidden="true"></i></button>
</div>
</form>Context & attachments
What the assistant can see is stated, not inferred. “Explain this bit” only means the paragraph in front of you if the lesson is genuinely in scope — and the student must be able to take it out.
markup
<form class="ns-aicomposer">
<div class="ns-aifiles">
<span class="ns-aicontext"><i class="ph ph-book-open-text" aria-hidden="true"></i> Apex basics · lesson 03
<button type="button" class="ns-aicontext__x" aria-label="Remove this lesson from context"><i class="ph ph-x" aria-hidden="true"></i></button></span>
<span class="ns-aifile"><i class="ph ph-file-text" aria-hidden="true"></i><span class="ns-aifile__name">AccountTrigger.cls</span><span class="ns-aifile__size">4 KB</span>
<button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--xs" aria-label="Remove AccountTrigger.cls"><i class="ph ph-x" aria-hidden="true"></i></button></span>
<span class="ns-aifile" data-state="uploading"><i class="ph ph-circle-notch" aria-hidden="true"></i><span class="ns-aifile__name">error-log.txt</span><span class="ns-aifile__size">uploading</span></span>
<span class="ns-aifile" data-state="failed"><i class="ph ph-warning" aria-hidden="true"></i><span class="ns-aifile__name">flow-screenshot.png</span><span class="ns-aifile__size">too large</span></span>
</div>
<label class="ns-visually-hidden" for="ai-doc-in2">Ask the assistant</label>
<textarea class="ns-aicomposer__area" id="ai-doc-in2" rows="2" placeholder="What is wrong with this trigger?"></textarea>
<div class="ns-aicomposer__bar">
<button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Attach a file"><i class="ph ph-folder-open" aria-hidden="true"></i></button>
<span class="ns-aicomposer__spacer"></span>
<span class="ns-aicomposer__count" data-state="near">3620 / 4000</span>
<button type="submit" class="ns-btn ns-btn--primary ns-btn--icon ns-btn--sm" aria-label="Send message"><i class="ph ph-paper-plane-tilt" aria-hidden="true"></i></button>
</div>
</form>Mode picker
Which teacher you are talking to. Tutor explains, Coach answers with a question first, Reviewer marks the code you paste. The trigger is mono because it is a label; the menu is the shared ns-menu.
markup
<div style="display:flex;gap:var(--space-4);align-items:flex-start">
<button type="button" class="ns-aimode" aria-expanded="true" aria-haspopup="true"><i class="ph ph-graduation-cap" aria-hidden="true"></i> Tutor <i class="ph ph-caret-down" aria-hidden="true"></i></button>
<div class="ns-menu" style="position:static;display:block;inline-size:17rem">
<p class="ns-menu__label">Mode</p>
<button type="button" class="ns-menu__item" aria-current="true"><i class="ph ph-graduation-cap" aria-hidden="true"></i> Tutor <span>explains, then shows</span></button>
<button type="button" class="ns-menu__item"><i class="ph ph-question" aria-hidden="true"></i> Coach <span>asks you first</span></button>
<button type="button" class="ns-menu__item"><i class="ph ph-code" aria-hidden="true"></i> Reviewer <span>marks your Apex</span></button>
</div>
</div>Signed out
Visible but disabled, with the reason on it. A composer that is simply gone reads as a broken page.
markup
<div class="ns-aicomposer" aria-disabled="true">
<label class="ns-visually-hidden" for="ai-doc-in3">Ask the assistant</label>
<textarea class="ns-aicomposer__area" id="ai-doc-in3" rows="2" disabled placeholder="Sign in to ask a question…"></textarea>
<div class="ns-aicomposer__bar">
<span class="ns-aicomposer__hint"><i class="ph ph-lock-simple" aria-hidden="true"></i> Sign in to send</span>
<span class="ns-aicomposer__spacer"></span>
<button type="button" class="ns-btn ns-btn--primary ns-btn--sm">Sign in</button>
</div>
</div>Accessibility contract
- The textarea has a real label, visually hidden — a placeholder is not a label
- The focus ring is on the wrapper, so the whole control shows focus rather than an inner box
- aria-disabled on the wrapper for the signed-out state, with the reason in the bar rather than a dead control