# Form Layouts & Design Source: https://www.ghostwriter.wiki/coding-style-guide/form-layouts-and-design Creation and layout of forms ## Introduction The Ghostwriter project uses the *django-crispy-forms* library (Crispy) to layout forms. At a basic level, this library provides template tags for rendering form fields that are more visually appealing than the regular Django form fields. [GitHub - django-crispy-forms/django-crispy-forms](https://github.com/django-crispy-forms/django-crispy-forms) Ghostwriter leverages the library's more advanced features to create `FormHelper()` and `Layout()` objects to design reusable forms in code. This requires some additional work upfront, but the end result should be a form that can be modified in one location (the *forms.py* file) and reused in multiple views and templates. If created correctly, the form can be added to a template with two lines: ```json theme={"system"} {% load crispy_forms_tags %} {% crispy form form.helper %} ``` Read about Crispy's `FormHelper()` and `Layout()` objects: ## Creating a Form Most forms should be instances of Django's `models.ModelForm` class. This makes it simpler to create a form for most, if not all, of Ghostwriter's use cases for a form. The name of the form should identify the related model and include an appropriate docstring. ```json theme={"system"} class ClientContactForm(forms.ModelForm): """ Create an individual :model:`rolodex.ClientContact` for a :model:`rolodex.Client`. """ class Meta: model = ClientContact exclude = ("client",) ``` Avoid naming forms with adjectives like "create" (ex: `ClientContactCreate`) because the forms should be reusable. A form used to create a new model entry should be reusable to update that same entry. The `Meta` class of every instance of `ModelForm` must declare the model as the variable`model` and then provide values for either `fields` or `exclude`. Use `exclude` to declare which fields should not be included in the form. If the form applies to a specific scenario where `exclude` would be a bigger list than `fields`, then use `fields` to declare which fields to include. Finally, if the form will include all fields, explicitly state this by setting `fields = "__all__"`. ### Forms.py Forms should be added to the application's *forms.py* file and then imported using a line like `from .forms import FORM_NAME`. When form files become large (like when dealing with multiple parent forms and child formsets), the files become difficult to navigate., so there is one exception to this rule: If the *forms.py* file grows into an excessively large file, split the file into two or more files organized by model. The Rolodex application's form files are a good example of this situation. All of the forms related to the `Client` and `Project` models and their associated child models pushed the single *forms.py* file beyond 1,000 lines. To make it easier to maintain each set of forms, the project moved them into files named \_forms.*client.py* and *forms\_project.py*. Name the new files with the *forms*\_ prefix followed by the model's name. ## Basic Form Design The Django form attributes control field attributes. The Crispy `FormHelper()` and `Layout()` objects control the `
` tags and the layout of the fields, respectively. ### Setting Field Attributes Django allows for field attributed to be configured in the form class. For `ModelForm` instances, this is done in the form's `__init__` method. Attributes should be set at the top of the `__init__` method, like so: ```json theme={"system"} def __init__(self, *args, **kwargs): super(ClientContactForm, self).__init__(*args, **kwargs) self.fields["name"].widget.attrs["placeholder"] = "David McQuire" self.fields["name"].widget.attrs["autocomplete"] = "off" self.fields["email"].widget.attrs["placeholder"] = "info@specterops.io" self.fields["email"].widget.attrs["autocomplete"] = "off" self.fields["job_title"].widget.attrs["placeholder"] = "CEO" self.fields["job_title"].widget.attrs["autocomplete"] = "off" self.fields["phone"].widget.attrs["placeholder"] = "(800) 444-4444" self.fields["phone"].widget.attrs["autocomplete"] = "off" self.fields["note"].widget.attrs[ "placeholder" ] = "Additional notes for the contact" ``` A `placeholder` attribute should be set for all fields. The placeholder text should provide an example of the intended content or an example of the proper input format. In more freeform fields, the placeholder should identify the field and guide the user towards its intended purpose (e.g., the `note` field in the above example). All fields should set `autocomplete` to `off` unless it is explicitly needed. Otherwise, autocomplete behavior can negatively impact user experience (e.g., autocomplete lists appearing and covering datepickers) or display non-public information when clicking on the field. The latter is mostly an issue for demonstrations of Ghostwriter. ### FormHelper Object Every form should have a `FormHelper()` object named `self.helper`. Create a `FormHelper()` object named in the form's `__init__` method. At a minimum, Ghostwriter form helpers set several values. These values ensure the form appears correctly in the content of the rendered webpage. ```json theme={"system"} # Design form layout with Crispy FormHelper self.helper = FormHelper() # Explicitly turn on/off
tags for the form self.helper.form_tag = True # Explicitly state if labels should be displayed self.helper.form_show_labels = False # Set a class for the form from the stylesheet self.helper.form_class = "newitem" ``` The form labels should be hidden if the form is easy to understand from placeholders or context. Formsets require some additional modifications to the `FormHelper()` configuration. See the next section for more information. ### Layout Object Every form should have a `Layout()` object assigned to the `FormHelper()` object's `layout` attribute, `self.helper.layout`. This object controls the form's HTML. Crispy uses a collection of HTML templates assigned to different `crispy_forms.layout` and `crispy_forms.bootstrap` classes to generate elements when the form is rendered. Many of these classes take all kwargs and pass them to the HTML templates as attributes. This means adding something like `id="my-div-id"` to an instance of `Div()` will result in Crispy using this to the render the div and set the div's `id` attribute to *my-div-id*. Some HTML attributes are keywords in Python, like `class`, so they require using a different argument. To set the class attribute, use `css_class`. This is powerful and makes it easy to maintain and modify the form, but it does mean style changes require more than saving a template and refreshing the webpage. While in `DEBUG` mode for development, every save action will restart the server. Plan form changes carefully to avoid excessive wait times for restarting the server. The layout for a basic `ClientNote` form might look like this example. This form display a single `TextArea` for a note. The only other fields are hidden fields used for associating the entry with an individual `Client` and individual `Users`. ```json theme={"system"} def __init__(self, *args, **kwargs): super(ClientNoteForm, self).__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_method = "post" self.helper.form_class = "newitem" self.helper.form_show_labels = False self.helper.layout = Layout( Div("note", "operator", "client"), ButtonHolder( Submit("submit-button", "Submit", css_class="btn btn-primary col-md-4"), HTML( """ """ ), ), ) ``` All of the fields are inside of an instance of the `Div()` class so they appear wrapped in `
` tags. While not used in this example, this allows for specifying a CSS class and other attributes for the div. The form concludes with a button to save the note and a button to cancel and leave the form. #### Button Controls Every form must end with at least two buttons, a submit button to save the entry and a cancel button to abandon the form. Assign the Submit button these CSS classes: `css_class="btn btn-primary col-md-4"` Assign the Cancel button these CSS classes: `css_class="btn btn-outline-secondary col-md-4` The submit button should be an instance of Crispy's `Submit()` class. This class accepts a name and a value. In most examples online the `name` parameter is set to *submit*. The name can be anything, but using *submit-button* is generally preferred. The value should always be *Submit*. In many examples, including Crispy's own documentation, the `value` parameter is set to `submit`. This works in most cases, but can cause unintended issues if the form is ever used with JavaScript. Setting the name of any field or button to *submit* masks the form's `submit()` method. Do not name submit buttons *submit*. Ghostwriter passes a `cancel_link` context variable to templates with forms. This variable contains a pre-defined URL that will return the user to a sensible location if they choose to abandon the form. Use Crispy's `HTML()` class to create a button with an `onclick` attribute that uses the `cancel_link` context variable and sets the other necessary values for the button. ```json theme={"system"} HTML( """ """ ), ``` The cancel button could be an instance of `Button()` with an `onclick` kwarg, but Django will not render context variables passed to the template in this way. #### Organizing Large Forms Large forms can be unwieldy, especially if the form contains one or more inline formsets that users can add to the form to make it longer. Large forms should be organized into Bootstrap tabs using Crispy's `Tabholder()` class. This class must contain tabs that hold different sections of the form. Ghostwriter does not use Crispy's `Tab()` class. Instead, there is a `CustomTab()` class available in the project that allows for additional customization of the tabs. # Custom Layout Objects Source: https://www.ghostwriter.wiki/coding-style-guide/form-layouts-and-design/custom-layout-objects The Ghostwriter project's custom additions to django-crispy-forms # Formset Layout & Design Source: https://www.ghostwriter.wiki/coding-style-guide/form-layouts-and-design/formset-layout-and-design Creation and layout of inline formsets ## Keep Formsets as Infrastructure Django inline formsets remain the preferred server-side mechanism when a parent form creates or updates a bounded collection of child models in one transaction. They provide mature handling for: * Management counts and prefixes * Existing and new child instances * Per-field and cross-form validation * Hidden deletion flags * Atomic parent/child submission * Re-rendering submitted values after a validation error The browser does not need to expose the collection as a traditional stack of forms. Ghostwriter's refreshed pattern treats the formset as infrastructure and presents it as a collection editor. Use a separate CRUD workflow instead when child objects must save independently, require different permissions, need real-time collaboration, or can grow into an unbounded dataset. Do not introduce separate endpoints only to make an inline formset look modern. ## Collection Editor Pattern `ClientForm` in `ghostwriter/rolodex/forms_client.py`, `rolodex/client_form.html`, and the project collection configuration in `rolodex/project_form.html` are the current references. The collection has four layers: 1. A `collection-toolbar` with the collection name, purpose, and Add action. 2. A `collection-empty-state` that identifies a genuinely empty collection. 3. One `collection-form-card` per submitted formset form. 4. The standard formset management and `empty_form` prototype. Each card uses a native `
` element: * The summary contains the child identity, useful metadata, optional status, and one chevron. * Existing valid objects begin collapsed. * New objects and objects containing validation errors begin open. * The body contains the unchanged Django fields and hidden `DELETE` input. * The Remove action sets `DELETE`; it does not remove or renumber the submitted DOM record. * The undo alert clears `DELETE` and restores the card. Use `.text()` or `textContent` to update summaries from input values. Do not place user-controlled values into raw JavaScript, selectors, inline handlers, or `innerHTML`. Values rendered into JavaScript still require `escapejs`; structured data still requires `json_script`. ## Formset Lifecycle ### Initial render * Render no artificial blank child merely to make the page look populated. `extra=0` is preferred when the relationship is optional. * Show an actionable empty state and let the user choose Add. * Count only formset forms inside the live formset container. Exclude the hidden element whose ID begins with `empty-form-`. * Preserve the management form and every existing primary-key field. * Some non-model or custom formsets do not render a primary-key field. Determine whether an indexed form is saved or newly added by comparing its index with the management form's `INITIAL_FORMS` value; do not infer this from whether visible fields happen to contain values. ### Add 1. Read `TOTAL_FORMS`. 2. Copy the hidden `empty_form` markup. 3. Replace every `__prefix__` with the new index. 4. Append the new form to the live formset container. 5. Increment `TOTAL_FORMS`. 6. Open the new collection card. 7. Initialize only editors inside the new card. 8. Refresh summary text, count badges, and empty state. Never reuse or decrement a submitted index. A removed form remains represented by its index and `DELETE` value. ### Remove and undo The existing global formset behavior in `base_generic.html` sets or clears the hidden `DELETE` input and presents an undo alert. Collection editors may additionally hide the surrounding summary card and update visible counts, but they must not replace that submission behavior. Use **Remove** for an existing child and **Discard** when an unsaved child can be distinguished reliably. Avoid **Delete** when the record is only staged for deletion until the parent form is saved. ### Validation errors * Select the tab containing the first invalid control. * Open the collection card containing the invalid field or feedback element. * Keep the submitted values and formset indices. * Initialize an errored rich-text field only after its card is visible. * Do not silently discard an invalid form because another form in the collection is valid. ## Rich-Text Fields Repeated rich-text fields are the main performance and stability risk in formsets. * Add `gw-tiptap-compact` to short supporting notes or comments. * Add `gw-tiptap-narrative` only when the child genuinely needs longer prose. * Leave editors inside closed `collection-form-card` details unmounted. * Initialize an editor when its card opens. * Exclude the hidden `empty_form` prototype from automatic initialization. * Use `window.gwInitTiptapStableContainer()` for expandable cards and collapsible regions. It initializes the visible collection without focusing it and restores the triggering viewport position. * Keep rich-text editors in inactive task tabs lazy. Prepare the destination pane when its tab is first requested rather than blocking initial page scrolling to warm every hidden editor. Switching to the collection's tab should not mount every editor. Browser QA should confirm that opening one card creates one editor, switching tabs does not change `scrollY`, and editors do not render collapsed. ## Visual and Interaction Rules * Keep collection cards compact when closed—identity on the left, high-value status and one chevron on the right. * Rotate only the chevron, not the summary or cell. * Let Add, Delete, and Remove buttons size to their label with a shared minimum width. Do not impose a fixed width that truncates object-specific action text. * Use neutral borders and surfaces. Violet identifies selection or current context; green is reserved for successful or healthy state. * Add buttons belong beside the section heading on wider screens and below it on narrow screens. * Repeat the same Add action beside Remove/Delete in every entry's action bar. Collapsing a card hides its actions; keep summaries focused on entry details. Keep actions within the card boundary and use equal vertical padding around the action bar. The shared `collection-add-footer.js` forwards the action to the header button without duplicating formset handlers or IDs. * Tab counts are neutral metadata, not bright notification badges. * Summaries truncate safely on desktop and retain all actions on narrow screens. * Use a sticky Save/Cancel surface when an open card makes the form substantially taller than the viewport. * Respect `prefers-reduced-motion`. ## Applying the Pattern to Another Form Before changing markup, identify: * The parent model and every submitted formset prefix * Required, optional, and mutually exclusive child fields * Existing add, delete, undo, ordering, and validation behavior * Rich-text or file controls that initialize JavaScript * Whether zero child objects is valid * The summary fields operators need to distinguish collapsed objects Then: 1. Keep the formset factory and validation unchanged. 2. Stop forcing a blank `extra` form if the relationship is optional. 3. Add a toolbar and explicit empty state. 4. Wrap each child helper layout in `collection-form-card`. 5. Preserve hidden `id`, foreign-key, ordering, and `DELETE` fields. 6. Add safe summary synchronization and tab counts. 7. Make closed rich-text cards lazy. 8. Add structural tests for management fields, cards, empty state, and invalid forms. 9. Test add, remove, undo, submit, and validation in the installed Playwright browser. 10. Review at 1440, 1024, 768, and 390 pixels in light and dark themes. Do not copy page-specific contact or access labels into another workflow. Reuse the interaction model and semantic classes, then choose summaries and actions that match the operator's actual task. ## Regression Checklist * [ ] `TOTAL_FORMS` increments exactly once for every Add action. * [ ] Existing hidden primary keys remain present. * [ ] Remove checks the intended `DELETE` input. * [ ] Undo restores both the input state and visible card. * [ ] Empty state and tab count exclude deleted forms and the `empty_form` prototype. * [ ] Invalid child forms reappear open with errors and submitted values. * [ ] Cross-form validation still runs. * [ ] Rich-text fields save their editor content. * [ ] Closed cards do not mount editors. * [ ] Tab switching does not flicker or change scroll position. * [ ] Keyboard users can open summaries and reach every action. * [ ] There is no document-level horizontal overflow. * [ ] Light and dark themes retain readable controls, menus, and editor content. # Overview Source: https://www.ghostwriter.wiki/coding-style-guide/style-guide Coding style guide for the Ghostwriter code base ## Introduction To maintain consistency as the project develops, all contributors to the project should keep the following requirements in mind before committing code or submitting a pull request. Using Visual Studio Code (VSCode) makes it easier to follow this style guide. If a developer uses a different editor, pre-commit hooks are provided at the bottom of the page. ## Code Formatting The Ghostwriter project uses Python Black to enforce code style. Logo Microsoft's official Python extension for VSCode supports formatters like Black. Microsoft's documentation covers this topic: [https://code.visualstudio.com/docs/python/editingcode.visualstudio.com](https://code.visualstudio.com/docs/python/editing) Black is "opinionated" and automatically changes things to keep code consistent – like intelligently changing single quotes to double quotes. 1. Install the Python extension in VSCode (SHIFT+CMD+X and search for `python`) 2. Install Black for the Python interpreter or virtual environment selected for VSCode 3. Add the following settings to VSCode's *settings.json*: ```json theme={"system"} "editor.formatOnSave": true, "python.formatting.provider": "black", "python.formatting.blackArgs": [ "--line-length", "90" ], ``` VSCode will now use Black to format all Python files on save actions. ### Managing Whitespace Black will not automatically delete trailing whitespace or whitespace on otherwise empty lines. Trailing whitespace will be identified by the linter (below). Add this line to VSCode's *settings.json* file to automatically delete trailing whitespace on save: ```json theme={"system"} "files.trimTrailingWhitespace": true ``` ## Managing Imports The Ghostwriter project also uses `Isort` which is part of the Python extensions `Python Refactor` toolkit (`Python Refactor: Sort Imports`). [![Logo](https://pypi.org/static/images/favicon.6a76275d.ico)isortPyPI](https://pypi.org/project/isort/) This tool sorts imports by library types (FUTURE, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER) and then alphabetically. It is customizable to generate comments and sort by line length. Ghostwriter code repository includes an *.isort.cfg* file. Ghostwriter's current configuration contains: ```json theme={"system"} [settings] profile=ghostwriter src_paths=isort,test atomic=True line_length=90 use_parentheses=True ensure_newline_before_comments=True import_heading_stdlib=Standard Libraries import_heading_firstparty=Ghostwriter Libraries import_heading_thirdparty=Django & Other 3rd Party Libraries ``` This configuration enforces the use of parentheses, adds newlines between sections, and keeps the line length /\<= 90 characters. It also adds custom comments before standard libraries, third-party libraries, and Ghostwriter's local first-party libraries. Here is an example: ```json theme={"system"} # Ghostwriter Libraries from ghostwriter.modules import codenames from .filters import ClientFilter, ProjectFilter from .forms import ( AssignmentCreateForm, ClientContactCreateForm, ClientCreateForm, ClientNoteCreateForm, ProjectAssignmentFormSet, ProjectCreateForm, ProjectForm, ProjectNoteCreateForm, ProjectObjectiveCreateForm, ProjectObjectiveFormSet ) from .models import ( Client, ClientContact, ClientNote, ObjectiveStatus, Project, ProjectAssignment, ProjectNote, ProjectObjective ) ``` Once the Python extension is installed, run `isort` by pressing SHIFT+CMD+P and selecting `Python Refactor: Sort Imports`. ## Line Length The Ghostwriter project enforces a 90 to 119-character line length limit. The PEP-8 style guide says to limit lines to 79-characters, but that leads to longer files that use half the horizontal space. The Django Project enforces 119-character lines because that's the maximum characters displayed (without scrolling) by GitHub's code viewer. Black defaults to 88, but says "90-ish is a wise choice." See here: [https://black.readthedocs.io/en/stable/the\_black\_code\_style.html#line-lengthblack.readthedocs.io](https://black.readthedocs.io/en/stable/the_black_code_style.html#line-length) > You probably noticed the peculiar default line length. *Black* defaults to 88 characters per line, which happens to be 10% over 80. This number was found to produce significantly shorter files than sticking with 80 (the most popular), or even 79 (used by the standard library). In general, [90-ish seems like the wise choice](https://youtu.be/wf-BqAjZb8M?t=260). The Ghostwriter project does not use 88 because it is registered as a numerical hate symbol by the Anti-Defamation League. `Isort` defaults to 79 to match PEP-8, so a line length must be configured to avoid style conflicts. For these reasons, the Ghostwriter project requires maximum line length be between 90 and 119-characters to keep everything comfortable to read in code editors and on GitHub. Any lines shorter than 90 should not be split, and longer lines should not exceed 119-characters without reason. ## Docstrings The Ghostwriter project requires consistent docstrings for all views, functions, forms, models, and other classes and objects. Ghostwriter's docstrings deviate from PEP-8 in favor of Django's style. Django can read docstrings and generate documentation. That only works for database models and views, but the style should be applied to other parts of the project for consistency. See Django's documentation: [![Logo](https://static.djangoproject.com/img/icon-touch.e4872c4da341.png)The Django admin documentation generator | Django documentation | Django](https://docs.djangoproject.com/en/3.0/ref/contrib/admin/admindocs/) A good view docstring looks like this: ```json theme={"system"} class ClientDetailView(LoginRequiredMixin, generic.DetailView): """ Display an individual :model:`rolodex.Client`. **Context** ``domains`` List of :model:`shepherd.Domain` associated with :model:`rolodex.Client`. ``servers`` List of :model:`shepherd.StaticServer` associated with :model:`rolodex.Client`. ``vps`` List of :model:`shepherd.TransientServer` associated with :model:`rolodex.Client`. **Template** :template:`rolodex/client_detail.html` """ ``` Note the newline after the opening `"""` which deviates from standard practice (per PEP-8). Further, the use of grave accents, asterisks ( \* ), and colons ( : ). are all purposeful and important. Django and `docutils` convert these symbols into formatting for the auto-generated documentation in the admin panel. The above example is rendered like this: ![](https://www.ghostwriter.wiki/~gitbook/image?url=https%3A%2F%2F1408755273-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fassets%252F-LlOJdrSdgKfLf_tEm3S%252F-MCTv60mzzLBgvfNOSj6%252F-MCU1XqjHK6x0DR10h2W%252Fimage.png%3Falt%3Dmedia%26token%3D528a4a2d-fef2-43dd-b61a-b986bfa2649c\&width=768\&dpr=4\&quality=100\&sign=71a427b\&sv=2) Anything wrapped in grave accent marks will be transformed into a link leading to the related object (e.g., a model). When seeking to emphasize a variable, function name, or something else that should not become a link, wrap the string in double grave accent marks, as seen around the context variables in the above example. Likewise, asterisks will cause a string to be rendered as a header. Do not wrap anything in asterisks that should not become a header for a section. Follow the documentation for the Django admin documentation generator to see how headers are used. Finally, docstrings should have newlines between sections and section headers. These newlines should be completely empty – i.e., they should have no whitespace. Setup VSCode snippets to create templates for repeated code snippets, like docstrings. ```json theme={"system"} "UpdateView Docstring": { "prefix": "update", "body": [ "\"\"\"", "Update an individual :model:`${1:model}`.", "", "**Template**", "", ":template:`${2:template}`", "\"\"\"", ], "description": "A docstring template for an UpdateView" }, ``` ## Linting The Ghostwriter project recommends using `flake8` to lint Ghostwriter's code. The VSCode Python extension natively supports linting and a variety of linters. [https://code.visualstudio.com/docs/python/lintingcode.visualstudio.com](https://code.visualstudio.com/docs/python/linting) The project recommends changing VSCode's default `PyLint` to `flake8` because this linter is much faster and snappier – especially with some of Ghostwriter's longer Python files (e.g., a *views.py*). The `flake8` linter is logical and stylistic, like `PyLint`. Black should handle most of the linting, but it won't flag unused imports. When the linter returns errors or warnings, VSCode changes the filename to yellow or red. The editor also displays squiggles under the affected lines. Address all linting issues before committing any code. At a minimum, eliminate trailing whitespace and remove unused imports. ## Pre-commit Hooks In lieu of the VS Code extensions and configurations, developers can use pre-commit hooks to catch style guide violations. Find the project documentation here: Logo From the documentation's introduction: > Git hook scripts are useful for identifying simple issues before submission to code review. We run our hooks on every commit to automatically point out issues in code such as missing semicolons, trailing whitespace, and debug statements. By pointing these issues out before code review, this allows a code reviewer to focus on the architecture of a change while not wasting time with trivial style nitpicks. First, install the library by running `pip install pre-commit` for your local development environment. Add a `.pre-commit-config.yaml` file to the project's root directory. Use the example below as a model for this file. Once the file is in place, run `pre-commit install` to hook future git commits. ```json theme={"system"} exclude: 'docs|node_modules|migrations|.git|.tox' default_stages: [commit] fail_fast: true repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: master hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - repo: https://github.com/psf/black rev: 19.10b0 hooks: - id: black - repo: https://gitlab.com/pycqa/flake8 rev: 3.8.3 hooks: - id: flake8 args: ['--config=setup.cfg'] additional_dependencies: [flake8-isort] ``` # UI Refresh Design System Source: https://www.ghostwriter.wiki/coding-style-guide/ui-refresh-design-system Design direction, implementation patterns, and audit checklist for Ghostwriter's refreshed interface ## Purpose and Status This is the living implementation guide for Ghostwriter's UI refresh. Read it before changing the application shell, adding a page, modernizing an existing template, or auditing a page for consistency. The refresh is still in progress. The client, project, and report dashboards have received the most review, along with their related workflows and selected forms. The main dashboard, libraries, findings, observations, evidence, report templates, and administrative pages are at different stages of migration. A page using some new classes is not necessarily fully reviewed. Ghostwriter is an operator workspace for red teamers who need to move between engagement context, reporting content, infrastructure, and operational records quickly. The interface should feel like a focused operations console: dense enough to be useful, calm enough for long work sessions, and clear about the next action. The refresh is not an attempt to make Ghostwriter look like a generic SaaS application. Every visual element should help an operator answer one of these questions: * What engagement or object am I working on? * What requires my attention? * What can I do here? * Where can I go next? ## Non-Negotiable Constraints ### Offline operation Ghostwriter supports offline and air-gapped deployments. All fonts, icons, CSS, JavaScript, and component dependencies must be packaged in the repository. Do not add CDN references, remote font imports, analytics scripts, or runtime requests for UI assets. Bootstrap 5.3.8, Font Awesome, Poppins, Metrophobic, Roboto Mono, jQuery, Tiptap, and the other current dependencies are served from the repository. Vite packages Tiptap and its dependencies into `javascript/dist_frontend/`; Django serves that output from the local static asset path. ### Progressive migration The current UI is Django-rendered and still uses mature jQuery components. The direction is: 1. Establish a design that operators like in the server-rendered application. 2. Standardize semantic tokens and reusable component patterns. 3. Replace aging jQuery components incrementally. 4. Make those patterns portable to a future React application or SPA. Do not introduce Material UI or another React component system into Django templates. Use Bootstrap 5 where it provides behavior or layout, then add narrowly scoped Ghostwriter component classes where the product needs a distinct pattern. ### Security UI work must preserve the XSS hardening introduced around pull request 945. * Escape user-controlled values inserted into JavaScript with `escapejs`. * Pass structured data to JavaScript with Django's `json_script` filter. * Populate autocomplete tag lists through `get_tags_for_queryset()`. * Do not interpolate untrusted values into executable script, inline event handlers, selectors, or raw HTML. * Do not add `safe` merely to make new markup render. * Keep Bootstrap popovers, editor output, and rich content sanitization enabled. ### Behavior before appearance Modernization must not regress existing workflows. Preserve permissions, form saving, collaborative editing, sorting, filtering, drag-and-drop ordering, hash navigation, keyboard behavior, and editor initialization. ## Source of Truth The UI styles are currently split across migration layers: | File | Responsibility | | - | - | | `ghostwriter/static/css/design_system.css` | New semantic tokens and reusable page/component patterns | | `ghostwriter/static/css/app_shell.css` | Sidebar, active-engagement bar, responsive shell, and shell motion | | `ghostwriter/static/css/bootstrap5_compat.css` | Temporary compatibility for Bootstrap 4-era markup and plugins | | `ghostwriter/static/css/base_styles.css` | Legacy light/dark variables and component colors | | `ghostwriter/static/css/styles.css` | Legacy global and page-specific styles | | `ghostwriter/templates/base_generic.html` | Application shell, packaged dependencies, theme initialization | | `ghostwriter/templates/snippets/sidebar_navigation.html` | Compact and expanded navigation structures | | `javascript/src/frontend/standalone_tiptap.tsx` | Progressive enhancement for Django textarea fields | | `javascript/src/tiptap_gw/` | Shared rich-text schema and legacy HTML compatibility | New work should prefer semantic `--gw-*` tokens and shared classes in `design_system.css` or `app_shell.css`. Do not copy a legacy rule merely because it already exists. `--primary-color` is still green and remains widely used by older selectors; it is migration debt, not permission to use green for every interaction. The older Form Layouts & Design guide remains useful for Django forms, Crispy helpers, and formset architecture. This guide takes precedence where its visual advice differs: refreshed forms normally keep useful labels visible, group fields by task, and use the spacing and card patterns described below. Use Bootstrap utilities for ordinary spacing and layout. Prefer `mb-3`, `gap-3`, `d-flex`, or the grid utilities over a custom class that only adds the same margin or display rule. Create a custom class when it identifies a reusable Ghostwriter component or carries behavior that Bootstrap does not express. ## Rich-Text Editing Phase 1 of the editor migration uses the same Tiptap schema for standalone Django forms, Django admin extra-field defaults, and collaborative reporting editors. Standalone editors continue to post HTML through their original `