{
+ // Focus first matching element when modifier runs
+ element.querySelector(selector)?.focus();
+});
+```
+
```glimmer-js
// app/components/dropdown.gjs
import Component from '@glimmer/component';
@@ -3666,6 +4308,27 @@ class Dropdown extends Component {
**For focus trapping in modals, use ember-focus-trap:**
+```bash
+ember install ember-focus-trap
+```
+
+```glimmer-js
+// app/components/modal.gjs
+import FocusTrap from 'ember-focus-trap/components/focus-trap';
+
+
+ {{#if this.showModal}}
+
+
+
{{@title}}
+ {{yield}}
+
+
+
+ {{/if}}
+
+```
+
```bash
npm install @fluentui/keyboard-keys
```
@@ -3836,6 +4499,10 @@ module('Integration | Component | user-form', function (hooks) {
**Setup: install and configure**
+```bash
+ember install ember-a11y-testing
+```
+
```javascript
// tests/test-helper.js
import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';
@@ -3843,6 +4510,12 @@ import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';
setupGlobalA11yHooks(); // Runs on every test automatically
```
+Keep the default ember-a11y-testing and axe-core rules turned on. Avoid disabling rules globally or excluding specific rules to bypass failures without a documented remediation plan.
+
+When teams suppress accessibility rules without a time-boxed remediation plan, they hide real defects, accumulate technical debt, and make regressions harder to detect. A skipped rule can allow a serious accessibility defect to ship to production, especially for problems involving forms, keyboard access, focus management, semantics, or ARIA usage.
+
+If you must suppress a rule temporarily, treat it as an exception: document why it is needed, scope it as narrowly as possible, and create follow-up work to restore the rule quickly.
+
ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.
Reference: [https://github.com/ember-a11y/ember-a11y-testing](https://github.com/ember-a11y/ember-a11y-testing)
@@ -3983,6 +4656,221 @@ Use `RSVP.hash` or `Promise.all` for parallel loading:
**Correct: parallelized model loading**
+```javascript
+// app/routes/dashboard.js
+import Route from '@ember/routing/route';
+import { hash } from 'rsvp';
+
+export default class DashboardRoute extends Route {
+ async model() {
+ return hash({
+ user: this.store.request({ url: '/users/me' }),
+ posts: this.store.request({ url: '/posts?recent=true' }),
+ notifications: this.store.request({ url: '/notifications?unread=true' }),
+ });
+ }
+}
+```
+
+```javascript
+// app/services/api.js
+import Service, { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+export default class ApiService extends Service {
+ @service store;
+ @tracked lastError = null;
+
+ async fetchWithFallback(url, fallback = null) {
+ try {
+ const response = await this.store.request({ url });
+ this.lastError = null;
+ return response.content;
+ } catch (error) {
+ this.lastError = error.message;
+ console.error(`API Error fetching ${url}:`, error);
+ return fallback;
+ }
+ }
+
+ async fetchWithRetry(url, { maxRetries = 3, delay = 1000 } = {}) {
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ try {
+ return await this.store.request({ url });
+ } catch (error) {
+ if (attempt === maxRetries - 1) throw error;
+ await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)));
+ }
+ }
+ }
+}
+```
+
+```glimmer-js
+// app/components/search-results.gjs
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { restartableTask, timeout } from 'ember-concurrency';
+
+class SearchResults extends Component {
+ @service store;
+ @tracked results = [];
+
+ // Automatically cancels previous searches
+ @restartableTask
+ *searchTask(query) {
+ yield timeout(300); // Debounce
+
+ try {
+ const response = yield this.store.request({
+ url: `/search?q=${encodeURIComponent(query)}`,
+ });
+ this.results = response.content;
+ } catch (error) {
+ if (error.name !== 'TaskCancelation') {
+ console.error('Search failed:', error);
+ }
+ }
+ }
+
+
+
+
+ {{#if this.searchTask.isRunning}}
+ Searching...
+ {{else}}
+
+ {{#each this.results as |result|}}
+ - {{result.title}}
+ {{/each}}
+
+ {{/if}}
+
+}
+```
+
+```javascript
+// app/services/data-fetcher.js
+import Service, { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { registerDestructor } from '@ember/destroyable';
+
+export default class DataFetcherService extends Service {
+ @service store;
+ @tracked data = null;
+ @tracked isLoading = false;
+
+ abortController = null;
+
+ constructor() {
+ super(...arguments);
+ registerDestructor(this, () => {
+ this.abortController?.abort();
+ });
+ }
+
+ async fetch(url) {
+ // Cancel previous request
+ this.abortController?.abort();
+ this.abortController = new AbortController();
+
+ this.isLoading = true;
+ try {
+ // Note: WarpDrive handles AbortSignal internally
+ const response = await this.store.request({
+ url,
+ signal: this.abortController.signal,
+ });
+ this.data = response.content;
+ } catch (error) {
+ if (error.name !== 'AbortError') {
+ throw error;
+ }
+ } finally {
+ this.isLoading = false;
+ }
+ }
+}
+```
+
+```javascript
+// app/routes/post.js
+import Route from '@ember/routing/route';
+import { hash } from 'rsvp';
+
+export default class PostRoute extends Route {
+ async model({ post_id }) {
+ // First fetch the post
+ const post = await this.store.request({
+ url: `/posts/${post_id}`,
+ });
+
+ // Then fetch related data in parallel
+ return hash({
+ post,
+ author: this.store.request({
+ url: `/users/${post.content.authorId}`,
+ }),
+ comments: this.store.request({
+ url: `/posts/${post_id}/comments`,
+ }),
+ relatedPosts: this.store.request({
+ url: `/posts/${post_id}/related`,
+ }),
+ });
+ }
+}
+```
+
+```javascript
+// app/services/live-data.js
+import Service, { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { registerDestructor } from '@ember/destroyable';
+
+export default class LiveDataService extends Service {
+ @service store;
+ @tracked data = null;
+
+ intervalId = null;
+
+ constructor() {
+ super(...arguments);
+ registerDestructor(this, () => {
+ this.stopPolling();
+ });
+ }
+
+ startPolling(url, interval = 5000) {
+ this.stopPolling();
+
+ this.poll(url); // Initial fetch
+ this.intervalId = setInterval(() => this.poll(url), interval);
+ }
+
+ async poll(url) {
+ try {
+ const response = await this.store.request({ url });
+ this.data = response.content;
+ } catch (error) {
+ console.error('Polling error:', error);
+ }
+ }
+
+ stopPolling() {
+ if (this.intervalId) {
+ clearInterval(this.intervalId);
+ this.intervalId = null;
+ }
+ }
+}
+```
+
```javascript
// app/services/batch-loader.js
import Service, { service } from '@ember/service';
@@ -4152,6 +5040,33 @@ class DataProcessor extends Component {
**Factory pattern with owner:**
+```javascript
+// app/utils/logger-factory.js
+import { getOwner } from '@ember/application';
+
+class Logger {
+ constructor(owner, context) {
+ this.owner = owner;
+ this.context = context;
+ }
+
+ get config() {
+ // Access configuration service via owner
+ return getOwner(this).lookup('service:config');
+ }
+
+ log(message) {
+ if (this.config.enableLogging) {
+ console.log(`[${this.context}]`, message);
+ }
+ }
+}
+
+export function createLogger(owner, context) {
+ return new Logger(owner, context);
+}
+```
+
```glimmer-js
// Usage in component
import Component from '@glimmer/component';
@@ -4278,6 +5193,38 @@ export class AnalyticsTracker extends Component {
**Co-located services with components:**
+```javascript
+// app/components/shopping-cart/service.js
+import Service from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { TrackedArray } from 'tracked-built-ins';
+import { action } from '@ember/object';
+
+export class CartService extends Service {
+ @tracked items = new TrackedArray([]);
+
+ get total() {
+ return this.items.reduce((sum, item) => sum + item.price, 0);
+ }
+
+ @action
+ addItem(item) {
+ this.items.push(item);
+ }
+
+ @action
+ removeItem(id) {
+ const index = this.items.findIndex((item) => item.id === id);
+ if (index > -1) this.items.splice(index, 1);
+ }
+
+ @action
+ clear() {
+ this.items.clear();
+ }
+}
+```
+
```glimmer-js
// app/components/shopping-cart/index.gjs
import Component from '@glimmer/component';
@@ -4314,6 +5261,43 @@ class ShoppingCart extends Component {
**Service-like utilities in utils/ directory:**
+```javascript
+// app/utils/notification-manager.js
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { TrackedArray } from 'tracked-built-ins';
+import { setOwner } from '@ember/application';
+
+export class NotificationManager {
+ @tracked notifications = new TrackedArray([]);
+
+ constructor(owner) {
+ setOwner(this, owner);
+ }
+
+ @action
+ add(message, type = 'info') {
+ const notification = {
+ id: Math.random().toString(36),
+ message,
+ type,
+ timestamp: Date.now(),
+ };
+
+ this.notifications.push(notification);
+
+ // Auto-dismiss after 5 seconds
+ setTimeout(() => this.dismiss(notification.id), 5000);
+ }
+
+ @action
+ dismiss(id) {
+ const index = this.notifications.findIndex((n) => n.id === id);
+ if (index > -1) this.notifications.splice(index, 1);
+ }
+}
+```
+
```glimmer-js
// app/components/notification-container.gjs
import Component from '@glimmer/component';
@@ -4575,6 +5559,40 @@ export default class DashboardRoute extends Route {
**Correct: using service**
+```javascript
+// app/services/theme.js
+import Service from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+export default class ThemeService extends Service {
+ @tracked currentTheme = 'dark';
+
+ @action
+ setTheme(theme) {
+ this.currentTheme = theme;
+ localStorage.setItem('theme', theme);
+ }
+
+ @action
+ loadTheme() {
+ this.currentTheme = localStorage.getItem('theme') || 'dark';
+ }
+}
+```
+
+```javascript
+// app/components/header.js
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+
+class Header extends Component {
+ @service theme;
+
+ // Access theme.currentTheme directly
+}
+```
+
```javascript
// app/components/sidebar.js
import Component from '@glimmer/component';
@@ -4761,6 +5779,31 @@ Compose helpers to create reusable, testable logic that can be combined in templ
**Correct: composed helpers**
+```javascript
+// app/helpers/display-name.js
+export function displayName(name, { maxLength = 20 } = {}) {
+ if (!name) return '';
+
+ const truncated = name.length > maxLength ? name.slice(0, maxLength) + '...' : name;
+
+ return truncated.toUpperCase();
+}
+```
+
+```javascript
+// app/helpers/is-visible-user.js
+export function isVisibleUser(user) {
+ return user && user.isActive && !user.isDeleted;
+}
+```
+
+```javascript
+// app/helpers/format-email.js
+export function formatEmail(email) {
+ return email?.toLowerCase() || '';
+}
+```
+
```glimmer-js
// app/components/user-profile.gjs
import { displayName } from '../helpers/display-name';
@@ -4823,6 +5866,20 @@ const truncate = (str, length = 20) => str?.slice(0, length) || '';
**Higher-order helpers:**
+```javascript
+// app/helpers/partial-apply.js
+export function partialApply(fn, ...args) {
+ return (...moreArgs) => fn(...args, ...moreArgs);
+}
+```
+
+```javascript
+// app/helpers/map-by.js
+export function mapBy(array, property) {
+ return array?.map((item) => item[property]) || [];
+}
+```
+
```glimmer-js
// Usage in template
import { mapBy } from '../helpers/map-by';
@@ -4845,6 +5902,43 @@ import { partialApply } from '../helpers/partial-apply';
**Chainable transformation helpers:**
+```javascript
+// app/helpers/transform.js
+class Transform {
+ constructor(value) {
+ this.value = value;
+ }
+
+ filter(fn) {
+ this.value = this.value?.filter(fn) || [];
+ return this;
+ }
+
+ map(fn) {
+ this.value = this.value?.map(fn) || [];
+ return this;
+ }
+
+ sort(fn) {
+ this.value = [...(this.value || [])].sort(fn);
+ return this;
+ }
+
+ take(n) {
+ this.value = this.value?.slice(0, n) || [];
+ return this;
+ }
+
+ get result() {
+ return this.value;
+ }
+}
+
+export function transform(value) {
+ return new Transform(value);
+}
+```
+
```glimmer-js
// Usage
import { transform } from '../helpers/transform';
@@ -4867,6 +5961,13 @@ function filter(items) {
**Conditional composition:**
+```javascript
+// app/helpers/when.js
+export function when(condition, trueFn, falseFn) {
+ return condition ? trueFn() : falseFn ? falseFn() : null;
+}
+```
+
```javascript
// app/helpers/unless.js
export function unless(condition, falseFn, trueFn) {
@@ -4963,6 +6064,16 @@ import { eq, not } from 'ember-truth-helpers'; // From ember-truth-helpers addon
**Custom helper with imports:**
+```javascript
+// app/utils/format-currency.js
+export function formatCurrency(amount, { currency = 'USD' } = {}) {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ }).format(amount);
+}
+```
+
```glimmer-js
// app/components/price-display.gjs
import { formatCurrency } from '../utils/format-currency';
@@ -5049,6 +6160,16 @@ import { formatDate } from '../utils/format-date';
**With Multiple Arguments:**
+```javascript
+// app/utils/format-currency.js
+export function formatCurrency(amount, currency = 'USD') {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ }).format(amount);
+}
+```
+
```glimmer-js
// app/components/price.gjs
import { formatCurrency } from '../utils/format-currency';
@@ -5109,6 +6230,22 @@ export function capitalize(text) {
**Common Helper Patterns:**
+```javascript
+// app/utils/string-helpers.js
+export function capitalize(text) {
+ return text.charAt(0).toUpperCase() + text.slice(1);
+}
+
+export function truncate(text, length = 50) {
+ if (text.length <= length) return text;
+ return text.slice(0, length) + '...';
+}
+
+export function pluralize(count, singular, plural) {
+ return count === 1 ? singular : plural;
+}
+```
+
```glimmer-js
// Usage
import { capitalize, truncate, pluralize } from '../utils/string-helpers';
@@ -5162,6 +6299,64 @@ Use `{{#if}}` / `{{#else if}}` / `{{#else}}` chains and extract computed logic t
**Correct:**
+```glimmer-js
+// app/components/user-list.gjs
+import Component from '@glimmer/component';
+
+class UserList extends Component {
+
+ {{#each @users as |user|}}
+
+ {{#if (eq user.role "admin")}}
+ {{user.name}} (Admin)
+ {{else if (eq user.role "moderator")}}
+ {{user.name}} (Mod)
+ {{else}}
+ {{user.name}}
+ {{/if}}
+
+ {{/each}}
+
+}
+```
+
+```glimmer-js
+// app/components/user-card.gjs
+import Component from '@glimmer/component';
+import { cached } from '@glimmer/tracking';
+
+class UserCard extends Component {
+ @cached
+ get isActive() {
+ return this.args.user.status === 'active' && this.args.user.lastLoginDays < 30;
+ }
+
+ @cached
+ get showActions() {
+ return this.args.canEdit && !this.args.user.locked && this.isActive;
+ }
+
+
+
+
{{@user.name}}
+
+ {{#if this.isActive}}
+
Active
+ {{else}}
+
Inactive
+ {{/if}}
+
+ {{#if this.showActions}}
+
+
+
+
+ {{/if}}
+
+
+}
+```
+
```glimmer-js
// app/components/task-list.gjs
import Component from '@glimmer/component';
@@ -5208,6 +6403,55 @@ Use `{{#if}}` to guard `{{#each}}` and avoid rendering empty states:
**Good:**
+```glimmer-js
+// app/components/content-gate.gjs
+import Component from '@glimmer/component';
+import { cached } from '@glimmer/tracking';
+
+class ContentGate extends Component {
+ @cached
+ get canViewPremium() {
+ return this.args.user?.isPremium && this.args.user?.hasAccess;
+ }
+
+
+ {{#if this.canViewPremium}}
+
+ {{else}}
+
+ {{/if}}
+
+}
+```
+
+```glimmer-js
+// app/components/media-viewer.gjs
+import Component from '@glimmer/component';
+import ImageViewer from './image-viewer';
+import VideoPlayer from './video-player';
+import AudioPlayer from './audio-player';
+import { cached } from '@glimmer/tracking';
+
+class MediaViewer extends Component {
+ @cached
+ get mediaType() {
+ return this.args.media?.type;
+ }
+
+
+ {{#if (eq this.mediaType "image")}}
+
+ {{else if (eq this.mediaType "video")}}
+
+ {{else if (eq this.mediaType "audio")}}
+
+ {{else}}
+ Unsupported media type
+ {{/if}}
+
+}
+```
+
```glimmer-js
// app/components/data-display.gjs
import Component from '@glimmer/component';
@@ -5351,6 +6595,36 @@ function isOnSale(product) {
**When to use class-based vs template-only:**
+```glimmer-js
+// Use class-based when:
+// - You need @cached for expensive computations accessed multiple times
+// - You have tracked state
+// - You need lifecycle hooks or services
+
+import Component from '@glimmer/component';
+import { cached } from '@glimmer/tracking';
+
+export class ProductList extends Component {
+ @cached
+ get sortedProducts() {
+ // Expensive sort, accessed in template multiple times
+ return [...this.args.products].sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ @cached
+ get filteredProducts() {
+ // Depends on sortedProducts - benefits from caching
+ return this.sortedProducts.filter((p) => p.category === this.args.selectedCategory);
+ }
+
+
+ {{#each this.filteredProducts as |product|}}
+ {{product.name}}
+ {{/each}}
+
+}
+```
+
```glimmer-js
// Use template-only when:
// - Simple transformations
@@ -5826,6 +7100,123 @@ Use helper libraries like `ember-truth-helpers` and `ember-composable-helpers`:
**Correct:**
+```glimmer-js
+// app/components/user-badge.gjs
+import Component from '@glimmer/component';
+import { eq } from 'ember-truth-helpers';
+
+class UserBadge extends Component {
+
+ {{! eq helper from ember-truth-helpers }}
+ {{#if (eq @user.role "admin")}}
+ Admin
+ {{/if}}
+
+}
+```
+
+```glimmer-js
+// app/components/comparison-examples.gjs
+import Component from '@glimmer/component';
+import { eq, not, and, or, lt, lte, gt, gte } from 'ember-truth-helpers';
+
+class ComparisonExamples extends Component {
+
+ {{! Equality }}
+ {{#if (eq @status "active")}}Active{{/if}}
+
+ {{! Negation }}
+ {{#if (not @isDeleted)}}Visible{{/if}}
+
+ {{! Logical AND }}
+ {{#if (and @isPremium @hasAccess)}}Premium Content{{/if}}
+
+ {{! Logical OR }}
+ {{#if (or @isAdmin @isModerator)}}Moderation Tools{{/if}}
+
+ {{! Comparisons }}
+ {{#if (gt @score 100)}}High Score!{{/if}}
+ {{#if (lte @attempts 3)}}Try again{{/if}}
+
+}
+```
+
+```glimmer-js
+// app/components/collection-helpers.gjs
+import Component from '@glimmer/component';
+import { array, hash } from 'ember-composable-helpers/helpers';
+import { get } from 'ember-composable-helpers/helpers';
+
+class CollectionHelpers extends Component {
+
+ {{! Create array inline }}
+ {{#each (array "apple" "banana" "cherry") as |fruit|}}
+ {{fruit}}
+ {{/each}}
+
+ {{! Create object inline }}
+ {{#let (hash name="John" age=30 active=true) as |user|}}
+ {{user.name}} is {{user.age}} years old
+ {{/let}}
+
+ {{! Dynamic property access }}
+ {{get @user @propertyName}}
+
+}
+```
+
+```glimmer-js
+// app/components/string-helpers.gjs
+import Component from '@glimmer/component';
+import { concat } from '@ember/helper'; // Built-in to Ember
+
+class StringHelpers extends Component {
+
+ {{! Concatenate strings }}
+
+ {{concat @user.firstName " " @user.lastName}}
+
+
+ {{! With dynamic values }}
+
+
+}
+```
+
+```glimmer-js
+// app/components/action-helpers.gjs
+import Component from '@glimmer/component';
+import { fn } from '@ember/helper'; // Built-in to Ember
+import { on } from '@ember/modifier';
+
+class ActionHelpers extends Component {
+ updateValue = (field, event) => {
+ this.args.onChange(field, event.target.value);
+ };
+
+ deleteItem = (id) => {
+ this.args.onDelete(id);
+ };
+
+
+ {{! Partial application with fn }}
+
+
+ {{#each @items as |item|}}
+
+ {{item.name}}
+
+
+ {{/each}}
+
+}
+```
+
```glimmer-js
// app/components/conditional-inline.gjs
import Component from '@glimmer/component';
@@ -5879,6 +7270,43 @@ class DynamicClasses extends Component {
**List Filtering:**
+```glimmer-js
+// app/components/filtered-list.gjs
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { cached } from '@glimmer/tracking';
+import { fn, concat } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { eq } from 'ember-truth-helpers';
+import { array } from 'ember-composable-helpers/helpers';
+
+class FilteredList extends Component {
+ @tracked filter = 'all';
+
+ @cached
+ get filteredItems() {
+ if (this.filter === 'all') return this.args.items;
+ return this.args.items.filter((item) => item.status === this.filter);
+ }
+
+
+
+
+ {{#each this.filteredItems as |item|}}
+
+ {{item.name}}
+
+ {{/each}}
+
+}
+```
+
```glimmer-js
// app/components/user-profile-card.gjs
import Component from '@glimmer/component';
@@ -6026,6 +7454,26 @@ export default class Button extends Component {
**Correct: {{on}} modifier**
+```glimmer-js
+// app/components/button.gjs
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { on } from '@ember/modifier';
+
+export default class Button extends Component {
+ @action
+ handleClick() {
+ console.log('clicked');
+ }
+
+
+
+
+}
+```
+
```glimmer-js
// app/components/scrollable.gjs
import Component from '@glimmer/component';
@@ -6051,6 +7499,90 @@ The `{{on}}` modifier supports standard event listener options:
**Available options:**
+```glimmer-js
+// app/components/input-field.gjs
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { on } from '@ember/modifier';
+
+export default class InputField extends Component {
+ @action
+ handleFocus() {
+ console.log('focused');
+ }
+
+ @action
+ handleBlur() {
+ console.log('blurred');
+ }
+
+ @action
+ handleInput(event) {
+ this.args.onChange?.(event.target.value);
+ }
+
+
+
+
+}
+```
+
+```glimmer-js
+// app/components/form.gjs
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { on } from '@ember/modifier';
+
+export default class Form extends Component {
+ @action
+ handleSubmit(event) {
+ event.preventDefault(); // Prevent page reload
+ event.stopPropagation(); // Stop event bubbling if needed
+
+ this.args.onSubmit?.(/* form data */);
+ }
+
+
+
+
+}
+```
+
+```glimmer-js
+// app/components/keyboard-nav.gjs
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { on } from '@ember/modifier';
+
+export default class KeyboardNav extends Component {
+ @action
+ handleKeyDown(event) {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ this.args.onActivate?.();
+ }
+
+ if (event.key === 'Escape') {
+ this.args.onCancel?.();
+ }
+ }
+
+
+
+ {{yield}}
+
+
+}
+```
+
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
@@ -6343,6 +7875,24 @@ Without abstracted test utilities:
**Incorrect: exposing DOM to consumers**
+```glimmer-js
+// my-library/src/components/data-grid.gjs
+export class DataGrid extends Component {
+
+
+
+
+ {{#each @rows as |row|}}
+
{{row.name}}
+ {{/each}}
+
+
+
+}
+```
+
```glimmer-js
// Consumer's test - tightly coupled to DOM
import { render, click } from '@ember/test-helpers';
@@ -6368,6 +7918,84 @@ test('sorting works', async function (assert) {
**Correct: providing DOM-abstracted test utilities**
+```glimmer-js
+// my-library/src/test-support/data-grid.js
+import { click, findAll } from '@ember/test-helpers';
+
+/**
+ * Test utility for DataGrid component
+ * Provides stable API regardless of internal DOM structure
+ */
+export class DataGridTestHelper {
+ constructor(containerElement) {
+ this.container = containerElement;
+ }
+
+ /**
+ * Sort by column name
+ * @param {string} columnName - Column to sort by
+ */
+ async sortBy(columnName) {
+ // Implementation detail hidden from consumer
+ const button = this.container.querySelector(`[data-test-sort="${columnName}"]`);
+ if (!button) {
+ throw new Error(`Column "${columnName}" not found`);
+ }
+ await click(button);
+ }
+
+ /**
+ * Get all row data
+ * @returns {Array
} Row text content
+ */
+ getRows() {
+ return findAll('[data-test-row]', this.container).map((el) => el.textContent.trim());
+ }
+
+ /**
+ * Get row by index
+ * @param {number} index - Zero-based row index
+ * @returns {string} Row text content
+ */
+ getRow(index) {
+ const rows = this.getRows();
+ return rows[index];
+ }
+}
+
+// Factory function for easier usage
+export function getDataGrid(container = document) {
+ const gridElement = container.querySelector('[data-test-data-grid]');
+ if (!gridElement) {
+ throw new Error('DataGrid component not found');
+ }
+ return new DataGridTestHelper(gridElement);
+}
+```
+
+```glimmer-js
+// my-library/src/components/data-grid.gjs
+// Component updated with test hooks (data-test-*)
+export class DataGrid extends Component {
+
+
+
+
+ {{#each @rows as |row|}}
+
{{row.name}}
+ {{/each}}
+
+
+
+}
+```
+
```glimmer-js
// Consumer's test - abstracted from DOM
import { render } from '@ember/test-helpers';
@@ -6389,6 +8017,57 @@ test('sorting works', async function (assert) {
**Benefits:**
+```glimmer-js
+// Stable test hooks that won't conflict with styling
+
+{{@errorMessage}}
+```
+
+```javascript
+/**
+ * @class FormTestHelper
+ * @description Test utility for Form component
+ *
+ * @example
+ * const form = getForm();
+ * await form.fillIn('email', 'user@example.com');
+ * await form.submit();
+ * assert.strictEqual(form.getError(), 'Invalid email');
+ */
+```
+
+```javascript
+// ✅ Semantic and declarative
+await modal.close();
+await form.fillIn('email', 'test@example.com');
+assert.true(dropdown.isOpen());
+
+// ❌ Exposes implementation
+await click('.modal-close-button');
+await fillIn('.form-field[name="email"]', 'test@example.com');
+assert.dom('.dropdown.is-open').exists();
+```
+
+```javascript
+export class FormTestHelper {
+ async fillIn(fieldName, value) {
+ const field = this.container.querySelector(`[data-test-field="${fieldName}"]`);
+ if (!field) {
+ throw new Error(
+ `Field "${fieldName}" not found. Available fields: ${this.getFieldNames().join(', ')}`,
+ );
+ }
+ await fillIn(field, value);
+ }
+
+ getFieldNames() {
+ return Array.from(this.container.querySelectorAll('[data-test-field]')).map(
+ (el) => el.dataset.testField,
+ );
+ }
+}
+```
+
```javascript
// addon/test-support/modal.js
import { click, find, waitUntil } from '@ember/test-helpers';
@@ -6516,6 +8195,39 @@ test('it renders', async function (assert) {
**Correct: direct component render when no args needed**
+```javascript
+// tests/integration/components/loading-spinner-test.js
+import { render } from '@ember/test-helpers';
+import LoadingSpinner from 'my-app/components/loading-spinner';
+
+test('it renders', async function (assert) {
+ // ✅ Simple: pass component directly when no args needed
+ await render(LoadingSpinner);
+
+ assert.dom('[data-test-spinner]').exists();
+});
+```
+
+```javascript
+// tests/integration/components/loading-spinner-test.js
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render } from '@ember/test-helpers';
+import LoadingSpinner from 'my-app/components/loading-spinner';
+
+module('Integration | Component | loading-spinner', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders without arguments', async function (assert) {
+ // ✅ Simple: pass component directly when no args needed
+ await render(LoadingSpinner);
+
+ assert.dom('[data-test-spinner]').exists();
+ assert.dom('[data-test-spinner]').hasClass('loading');
+ });
+});
+```
+
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
@@ -6836,6 +8548,34 @@ module('Integration | Component | search-box', function (hooks) {
**Testing with ember-concurrency tasks:**
+```glimmer-js
+// app/components/async-button.js
+import Component from '@glimmer/component';
+import { task } from 'ember-concurrency';
+
+export default class AsyncButtonComponent extends Component {
+ @task
+ *saveTask() {
+ yield this.args.onSave();
+ }
+
+
+
+
+}
+```
+
```glimmer-js
// tests/integration/components/async-button-test.js
import { module, test } from 'qunit';
@@ -7182,6 +8922,85 @@ test('class assertions', async function (assert) {
**Form Elements:**
+```javascript
+test('form assertions', async function (assert) {
+ await render(
+
+
+ ,
+ );
+
+ // Input value
+ assert.dom('input[type="text"]').hasValue('hello');
+
+ // Checkbox/radio state
+ assert.dom('input[type="checkbox"]').isChecked();
+ assert.dom('input[type="checkbox"]').isNotChecked();
+
+ // Disabled state
+ assert.dom('input[type="radio"]').isDisabled();
+ assert.dom('input[type="text"]').isNotDisabled();
+
+ // Required state
+ assert.dom('input').isRequired();
+ assert.dom('input').isNotRequired();
+
+ // Focus state
+ assert.dom('input').isFocused();
+ assert.dom('input').isNotFocused();
+});
+```
+
+```javascript
+test('chained assertions', async function (assert) {
+ await render();
+
+ assert.dom('button')
+ .exists()
+ .hasClass('btn-primary')
+ .hasAttribute('type', 'button')
+ .isNotDisabled()
+ .hasText('Submit')
+ .isVisible();
+});
+```
+
+```javascript
+test('custom messages', async function (assert) {
+ await render();
+
+ assert.dom('[data-test-username]')
+ .hasText(this.user.name, 'username is displayed correctly');
+
+ assert.dom('[data-test-avatar]')
+ .exists('user avatar should be visible');
+});
+```
+
+```javascript
+test('list items', async function (assert) {
+ await render(
+
+ );
+
+ // Exact count
+ assert.dom('[data-test-todo]').exists({ count: 5 });
+
+ // At least one
+ assert.dom('[data-test-todo]').exists({ count: 1 });
+
+ // None
+ assert.dom('[data-test-todo]').doesNotExist();
+});
+```
+
```javascript
test('accessibility', async function (assert) {
await render();
@@ -7327,6 +9146,54 @@ module('Integration | Component | data-loader', function (hooks) {
**Correct: using test waiters**
+```glimmer-js
+// app/components/data-loader.gjs
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { registerDestructor } from '@ember/destroyable';
+import { buildWaiter } from '@ember/test-waiters';
+
+const waiter = buildWaiter('data-loader');
+
+export class DataLoader extends Component {
+ @tracked data = null;
+ @tracked isLoading = false;
+
+ loadData = async () => {
+ // Register the async operation with test waiter
+ const token = waiter.beginAsync();
+
+ try {
+ this.isLoading = true;
+
+ // Simulate async data loading
+ const response = await fetch('/api/data');
+ this.data = await response.json();
+ } finally {
+ this.isLoading = false;
+ // Always end the async operation, even on error
+ waiter.endAsync(token);
+ }
+ };
+
+
+
+
+
+ {{#if this.isLoading}}
+
Loading...
+ {{/if}}
+
+ {{#if this.data}}
+
{{this.data}}
+ {{/if}}
+
+
+}
+```
+
```glimmer-js
// tests/integration/components/data-loader-test.js
import { module, test } from 'qunit';
@@ -7419,6 +9286,35 @@ export class PollingWidget extends Component {
**Test waiter with Services:**
+```glimmer-js
+// app/services/data-sync.js
+import Service from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { buildWaiter } from '@ember/test-waiters';
+
+const waiter = buildWaiter('data-sync-service');
+
+export class DataSyncService extends Service {
+ @tracked isSyncing = false;
+
+ async sync() {
+ const token = waiter.beginAsync();
+
+ try {
+ this.isSyncing = true;
+
+ const response = await fetch('/api/sync', { method: 'POST' });
+ const result = await response.json();
+
+ return result;
+ } finally {
+ this.isSyncing = false;
+ waiter.endAsync(token);
+ }
+ }
+}
+```
+
```glimmer-js
// tests/unit/services/data-sync-test.js
import { module, test } from 'qunit';
@@ -7557,6 +9453,17 @@ Set up recommended VSCode extensions and Model Context Protocol (MCP) servers fo
}
```
+```json
+{
+ "recommendations": [
+ "emberjs.vscode-ember",
+ "vunguyentuan.vscode-glint",
+ "esbenp.prettier-vscode",
+ "dbaeumer.vscode-eslint"
+ ]
+}
+```
+
Create a `.vscode/extensions.json` file in your project root to recommend extensions to all team members:
**ember-extension-pack** (or individual extensions):**
@@ -7571,6 +9478,13 @@ Create a `.vscode/extensions.json` file in your project root to recommend extens
**Glint 2 Extension** (for TypeScript projects):**
+```bash
+# Via command palette
+# Press Cmd+Shift+P (Mac) or Ctrl+Shift+P (Windows/Linux)
+# Type: "Extensions: Install Extensions"
+# Search for "Ember" or "Glint"
+```
+
```json
{
"github.copilot.enable": {
@@ -7633,6 +9547,48 @@ Configure MCP servers in `.vscode/settings.json` to integrate AI coding assistan
**Playwright MCP** (optional, `@playwright/mcp-server`):**
+```json
+{
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": "explicit"
+ },
+
+ "[glimmer-js]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[glimmer-ts]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+
+ "files.associations": {
+ "*.gjs": "glimmer-js",
+ "*.gts": "glimmer-ts"
+ },
+
+ "glint.enabled": true,
+ "glint.configPath": "./tsconfig.json",
+
+ "github.copilot.enable": {
+ "*": true
+ },
+
+ "mcp.servers": {
+ "ember-mcp": {
+ "command": "npx",
+ "args": ["@ember/mcp-server"],
+ "description": "Ember.js MCP Server"
+ },
+ "chrome-devtools": {
+ "command": "npx",
+ "args": ["@modelcontextprotocol/server-chrome-devtools"],
+ "description": "Chrome DevTools MCP Server"
+ }
+ }
+}
+```
+
```json
{
"compilerOptions": {
@@ -8187,6 +10143,35 @@ class PostCard extends Component {
**Correct: reusable helper**
+```glimmer-js
+// app/components/post-list.gjs
+import Component from '@glimmer/component';
+
+// Helper co-located in same file
+function formatRelativeDate(date) {
+ const dateObj = new Date(date);
+ const now = new Date();
+ const diffMs = now - dateObj;
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) return 'Today';
+ if (diffDays === 1) return 'Yesterday';
+ if (diffDays < 7) return `${diffDays} days ago`;
+ return dateObj.toLocaleDateString();
+}
+
+class PostList extends Component {
+
+ {{#each @posts as |post|}}
+
+ {{post.title}}
+
+
+ {{/each}}
+
+}
+```
+
```javascript
// app/components/blog/format-relative-date.js
export function formatRelativeDate(date) {
@@ -8208,6 +10193,31 @@ For helpers shared across multiple components in a feature, use a subdirectory:
**Alternative: shared helper in utils**
+```javascript
+// app/utils/format-relative-date.js
+// Flat structure - use subpath-imports in package.json for nicer imports if needed
+export function formatRelativeDate(date) {
+ const dateObj = new Date(date);
+ const now = new Date();
+ const diffMs = now - dateObj;
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) return 'Today';
+ if (diffDays === 1) return 'Yesterday';
+ if (diffDays < 7) return `${diffDays} days ago`;
+ return dateObj.toLocaleDateString();
+}
+```
+
+```glimmer-js
+// app/components/user-card.gjs
+import { formatRelativeDate } from '../utils/format-relative-date';
+
+
+ Joined: {{formatRelativeDate @user.createdAt}}
+
+```
+
```glimmer-js
// app/components/post-card.gjs
import { formatRelativeDate } from '../utils/format-relative-date';
@@ -8306,6 +10316,30 @@ export default modifier((element, [config]) => {
**Also correct: class-based modifier for complex state**
+```javascript
+// app/modifiers/chart.js
+import Modifier from 'ember-modifier';
+import { registerDestructor } from '@ember/destroyable';
+
+export default class ChartModifier extends Modifier {
+ chartInstance = null;
+
+ modify(element, [config]) {
+ // Cleanup previous instance if config changed
+ if (this.chartInstance) {
+ this.chartInstance.destroy();
+ }
+
+ this.chartInstance = new Chart(element, config);
+
+ // Register cleanup
+ registerDestructor(this, () => {
+ this.chartInstance?.destroy();
+ });
+ }
+}
+```
+
```glimmer-js
// app/components/chart.gjs
import chart from '../modifiers/chart';
@@ -8319,6 +10353,15 @@ import chart from '../modifiers/chart';
**For commonly needed modifiers, use ember-modifier helpers:**
+```javascript
+// app/modifiers/autofocus.js
+import { modifier } from 'ember-modifier';
+
+export default modifier((element) => {
+ element.focus();
+});
+```
+
```glimmer-js
// app/components/input-field.gjs
import autofocus from '../modifiers/autofocus';
@@ -8328,6 +10371,10 @@ import autofocus from '../modifiers/autofocus';
**Use ember-resize-observer-modifier for resize handling:**
+```bash
+ember install ember-resize-observer-modifier
+```
+
```glimmer-js
// app/components/resizable.gjs
import onResize from 'ember-resize-observer-modifier';
@@ -8397,6 +10444,76 @@ export default class TodoList extends Component {
**Correct: reactive array with @ember/reactive/collections**
+```glimmer-js
+// app/components/todo-list.gjs
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { trackedArray } from '@ember/reactive/collections';
+
+export default class TodoList extends Component {
+ todos = trackedArray([]); // ✅ Mutations are reactive
+
+ @action
+ addTodo(text) {
+ // Now this triggers re-render!
+ this.todos.push({ id: Date.now(), text });
+ }
+
+ @action
+ removeTodo(id) {
+ // This also triggers re-render!
+ const index = this.todos.findIndex((t) => t.id === id);
+ this.todos.splice(index, 1);
+ }
+
+
+
+ {{#each this.todos as |todo|}}
+ -
+ {{todo.text}}
+
+
+ {{/each}}
+
+
+
+}
+```
+
+```glimmer-js
+// app/components/user-cache.gjs
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { trackedMap } from '@ember/reactive/collections';
+
+export default class UserCache extends Component {
+ userCache = trackedMap(); // key: userId, value: userData
+
+ @action
+ cacheUser(userId, userData) {
+ this.userCache.set(userId, userData);
+ }
+
+ @action
+ clearUser(userId) {
+ this.userCache.delete(userId);
+ }
+
+ get cachedUsers() {
+ return Array.from(this.userCache.values());
+ }
+
+
+
+ {{#each this.cachedUsers as |user|}}
+ - {{user.name}}
+ {{/each}}
+
+ Cache size: {{this.userCache.size}}
+
+}
+```
+
```glimmer-js
// app/components/tag-selector.gjs
import Component from '@glimmer/component';
@@ -8488,6 +10605,16 @@ const plainArray3 = [...trackedSet];
**Functional array methods still work:**
+```javascript
+const todos = trackedArray([...]);
+
+// All of these work and are reactive
+const completed = todos.filter(t => t.done);
+const titles = todos.map(t => t.title);
+const allDone = todos.every(t => t.done);
+const firstIncomplete = todos.find(t => !t.done);
+```
+
```javascript
import { tracked } from '@glimmer/tracking';
diff --git a/.agents/skills/ember-best-practices/SKILL.md b/.agents/skills/ember-best-practices/SKILL.md
index 51c077f..f4aa58f 100644
--- a/.agents/skills/ember-best-practices/SKILL.md
+++ b/.agents/skills/ember-best-practices/SKILL.md
@@ -149,7 +149,7 @@ Each rule file contains:
Ember has excellent accessibility support through community addons:
- **ember-a11y-testing** - Automated accessibility testing in your test suite
-- **ember-a11y** - Route announcements and focus management
+- **ember-a11y-refocus** - Route announcements and focus management
- **ember-focus-trap** - Focus trapping for modals and dialogs
- **ember-page-title** - Accessible page title management
- **Platform-native validation** - Use browser's Constraint Validation API for accessible form validation
diff --git a/.agents/skills/ember-best-practices/rules/a11y-automated-testing.md b/.agents/skills/ember-best-practices/rules/a11y-automated-testing.md
index 1fe447d..059da40 100644
--- a/.agents/skills/ember-best-practices/rules/a11y-automated-testing.md
+++ b/.agents/skills/ember-best-practices/rules/a11y-automated-testing.md
@@ -69,6 +69,14 @@ import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';
setupGlobalA11yHooks(); // Runs on every test automatically
```
+### Leave All Rules Enabled
+
+Keep the default ember-a11y-testing and axe-core rules turned on. Avoid disabling rules globally or excluding specific rules to bypass failures without a documented remediation plan.
+
+When teams suppress accessibility rules without a time-boxed remediation plan, they hide real defects, accumulate technical debt, and make regressions harder to detect. A skipped rule can allow a serious accessibility defect to ship to production, especially for problems involving forms, keyboard access, focus management, semantics, or ARIA usage.
+
+If you must suppress a rule temporarily, treat it as an exception: document why it is needed, scope it as narrowly as possible, and create follow-up work to restore the rule quickly.
+
ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.
Reference: [ember-a11y-testing](https://github.com/ember-a11y/ember-a11y-testing)
diff --git a/.agents/skills/ember-best-practices/rules/a11y-form-labels.md b/.agents/skills/ember-best-practices/rules/a11y-form-labels.md
index c7b4eae..1186b7d 100644
--- a/.agents/skills/ember-best-practices/rules/a11y-form-labels.md
+++ b/.agents/skills/ember-best-practices/rules/a11y-form-labels.md
@@ -35,9 +35,7 @@ All form inputs must have associated labels, and validation errors should be ann
+
+ {{! other header content }}
+
+
+
+ {{outlet}}
+
+```
+
+If you are using GJS or GTS, import the component directly:
+
+```glimmer-js
+import { NavigationNarrator } from 'ember-a11y-refocus';
+
+
+
+
+
+ {{outlet}}
+
+
+```
+
+The addon ships minimal styles for the skip link and navigation message:
```javascript
-// app/routes/dashboard.js
-import Route from '@ember/routing/route';
-import { service } from '@ember/service';
+// app/app.js or app/app.ts
+import 'ember-a11y-refocus/styles/navigation-narrator.css';
+```
-export default class DashboardRoute extends Route {
- @service announcer;
+If you need to customize which transitions count as a route change, pass a validator function to `NavigationNarrator`:
- afterModel() {
- this.announcer.announce('Loaded dashboard with latest data');
+```javascript
+// app/controllers/application.js
+import Controller from '@ember/controller';
+import { defaultValidator } from 'ember-a11y-refocus';
+
+export default class ApplicationController extends Controller {
+ myCustomValidator(transition) {
+ if (transition.from?.name === 'special') {
+ return false;
+ }
+
+ return defaultValidator(transition);
}
}
```
+```handlebars
+{{! app/templates/application.hbs }}
+
+
+
+ {{outlet}}
+
+```
+
**Alternative: DIY approach with ARIA live regions:**
If you prefer not to use a library, you can implement route announcements yourself:
@@ -150,25 +187,6 @@ export default class ApplicationRoute extends Route {
}
```
-**Alternative: Use ember-page-title with announcements:**
-
-```bash
-ember install ember-page-title
-```
-
-```glimmer-js
-// app/routes/dashboard.gjs
-import { pageTitle } from 'ember-page-title';
-
-
- {{pageTitle "Dashboard"}}
-
-
- {{outlet}}
-
-
-```
-
Route announcements ensure screen reader users know when navigation occurs, improving the overall accessibility experience.
Reference: [Ember Accessibility - Page Titles](https://guides.emberjs.com/release/accessibility/page-template-considerations/)
diff --git a/.agents/skills/ember-best-practices/rules/route-model-caching.md b/.agents/skills/ember-best-practices/rules/route-model-caching.md
index 1d1f074..a60d613 100644
--- a/.agents/skills/ember-best-practices/rules/route-model-caching.md
+++ b/.agents/skills/ember-best-practices/rules/route-model-caching.md
@@ -11,8 +11,8 @@ Implement intelligent model caching strategies to reduce redundant API calls and
**Incorrect (always fetches fresh data):**
-```glimmer-js
-// app/routes/post.gjs
+```javascript
+// app/routes/post.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
@@ -23,21 +23,24 @@ export default class PostRoute extends Route {
// Always makes API call, even if we just loaded this post
return this.store.request({ url: `/posts/${params.post_id}` });
}
-
-
-
- {{@model.title}}
- {{@model.content}}
-
- {{outlet}}
-
}
```
+```glimmer-js
+// app/templates/post.gjs
+
+
+ {{@model.title}}
+ {{@model.content}}
+
+ {{outlet}}
+
+```
+
**Correct (with smart caching):**
-```glimmer-js
-// app/routes/post.gjs
+```javascript
+// app/routes/post.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
@@ -68,17 +71,20 @@ export default class PostRoute extends Route {
const fiveMinutes = 5 * 60 * 1000;
return Date.now() - cacheTime < fiveMinutes;
}
-
-
-
- {{@model.title}}
- {{@model.content}}
-
- {{outlet}}
-
}
```
+```glimmer-js
+// app/templates/post.gjs
+
+
+ {{@model.title}}
+ {{@model.content}}
+
+ {{outlet}}
+
+```
+
**Service-based caching layer:**
```javascript
@@ -123,8 +129,8 @@ export default class PostCacheService extends Service {
}
```
-```glimmer-js
-// app/routes/post.gjs
+```javascript
+// app/routes/post.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
@@ -141,21 +147,24 @@ export default class PostRoute extends Route {
const params = this.paramsFor('post');
await this.postCache.getPost(params.post_id, { forceRefresh: true });
}
-
-
-
- {{@model.title}}
- {{@model.content}}
-
- {{outlet}}
-
}
```
+```glimmer-js
+// app/templates/post.gjs
+
+
+ {{@model.title}}
+ {{@model.content}}
+
+ {{outlet}}
+
+```
+
**Using query params for cache control:**
-```glimmer-js
-// app/routes/posts.gjs
+```javascript
+// app/routes/posts.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
@@ -174,28 +183,31 @@ export default class PostsRoute extends Route {
options,
});
}
-
-
-
-
-
-
- {{#each @model as |post|}}
- - {{post.title}}
- {{/each}}
-
-
- {{outlet}}
-
}
```
+```glimmer-js
+// app/templates/posts.gjs
+
+
+
+
+
+ {{#each @model as |post|}}
+ - {{post.title}}
+ {{/each}}
+
+
+ {{outlet}}
+
+```
+
**Background refresh pattern:**
-```glimmer-js
-// app/routes/dashboard.gjs
+```javascript
+// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
@@ -214,17 +226,20 @@ export default class DashboardRoute extends Route {
return cached || this.store.request({ url: '/dashboard' });
}
-
-
-
-
Dashboard
-
Stats: {{@model.stats}}
-
- {{outlet}}
-
}
```
+```glimmer-js
+// app/templates/dashboard.gjs
+
+
+
Dashboard
+
Stats: {{@model.stats}}
+
+ {{outlet}}
+
+```
+
Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.
Reference: [WarpDrive Caching](https://warp-drive.io/)
diff --git a/.agents/skills/ember-best-practices/rules/route-templates.md b/.agents/skills/ember-best-practices/rules/route-templates.md
index e8ebdf1..96f5b6a 100644
--- a/.agents/skills/ember-best-practices/rules/route-templates.md
+++ b/.agents/skills/ember-best-practices/rules/route-templates.md
@@ -1,38 +1,15 @@
---
-title: Use Route Templates with Co-located Syntax
+title: Use Separate Route and Template Files
impact: MEDIUM-HIGH
impactDescription: Better code organization and maintainability
-tags: routes, templates, gjs, co-location
+tags: routes, templates, gjs, file-conventions
---
-## Use Route Templates with Co-located Syntax
+## Use Separate Route and Template Files
-Use co-located route templates with modern gjs syntax for better organization and maintainability.
+Keep route logic in `app/routes/*.js` and route templates in `app/templates/*.gjs`. Route classes imported from `@ember/routing/route` do not support inline `
` blocks.
-**Incorrect (separate template file - old pattern):**
-
-```glimmer-js
-// app/routes/posts.js (separate file)
-import Route from '@ember/routing/route';
-
-export default class PostsRoute extends Route {
- model() {
- return this.store.request({ url: '/posts' });
- }
-}
-
-// app/templates/posts.gjs (separate template file)
-
- Posts
-
- {{#each @model as |post|}}
- - {{post.title}}
- {{/each}}
-
-
-```
-
-**Correct (co-located route template):**
+**Incorrect (inline template inside a route class):**
```glimmer-js
// app/routes/posts.gjs
@@ -56,10 +33,37 @@ export default class PostsRoute extends Route {
}
```
-**With loading and error states:**
+**Correct (separate route module and template file):**
+
+```javascript
+// app/routes/posts.js
+import Route from '@ember/routing/route';
+
+export default class PostsRoute extends Route {
+ model() {
+ return this.store.request({ url: '/posts' });
+ }
+}
+```
```glimmer-js
-// app/routes/posts.gjs
+// app/templates/posts.gjs
+
+ Posts
+
+ {{#each @model as |post|}}
+ - {{post.title}}
+ {{/each}}
+
+
+ {{outlet}}
+
+```
+
+**With a separate template file for route UI:**
+
+```javascript
+// app/routes/posts.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
@@ -69,29 +73,32 @@ export default class PostsRoute extends Route {
model() {
return this.store.request({ url: '/posts' });
}
-
-
-
-
Posts
-
- {{#if @model}}
-
- {{#each @model as |post|}}
- - {{post.title}}
- {{/each}}
-
- {{/if}}
-
- {{outlet}}
-
-
}
```
+```glimmer-js
+// app/templates/posts.gjs
+
+
+
Posts
+
+ {{#if @model}}
+
+ {{#each @model as |post|}}
+ - {{post.title}}
+ {{/each}}
+
+ {{/if}}
+
+ {{outlet}}
+
+
+```
+
**Template-only routes:**
```glimmer-js
-// app/routes/about.gjs
+// app/templates/about.gjs
About Us
@@ -100,6 +107,6 @@ export default class PostsRoute extends Route {
```
-Co-located route templates keep route logic and presentation together, making the codebase easier to navigate and maintain.
+Keeping route classes and route templates in their conventional files matches Ember's supported routing model and makes examples easier to apply in real apps.
Reference: [Ember Routes](https://guides.emberjs.com/release/routing/)
diff --git a/skills-lock.json b/skills-lock.json
index 5e82542..b48e906 100644
--- a/skills-lock.json
+++ b/skills-lock.json
@@ -4,16 +4,19 @@
"ember-best-practices": {
"source": "nullvoxpopuli/agent-skills",
"sourceType": "github",
- "computedHash": "7909c3def6c4ddefb358d1973cf724269ede9f6cdba1dd2888e4e6072a897f3e"
+ "skillPath": "skills/ember-best-practices/SKILL.md",
+ "computedHash": "34e82668e2fa1e6b025ac8cf1015634484bf5d8fbc80e8aa642489d2df79498c"
},
"nak": {
"source": "soapbox-pub/nostr-skills",
"sourceType": "github",
+ "skillPath": "skills/nak/SKILL.md",
"computedHash": "710d3f3945ff421ed2b7f40ecd32c5e263bc029d43fe8f4fd1491a8013c7389a"
},
"nostr": {
"source": "soapbox-pub/nostr-skills",
"sourceType": "github",
+ "skillPath": "skills/nostr/SKILL.md",
"computedHash": "e1e6834c18d18a5deef4cd9555f6eee0fc0b968acf1c619253999eda76beab8e"
}
}